diff --git a/src/AmtocBots.Api/AmtocBots.Api.csproj b/src/AmtocBots.Api/AmtocBots.Api.csproj new file mode 100644 index 0000000..1b4d191 --- /dev/null +++ b/src/AmtocBots.Api/AmtocBots.Api.csproj @@ -0,0 +1,40 @@ + + + + net10.0 + enable + enable + AmtocBots.Api + AmtocBots.Api + + + + + + + + all + runtime; build; native; contentfiles; analyzers + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/AmtocBots.Api/BackgroundServices/MetricsPollingService.cs b/src/AmtocBots.Api/BackgroundServices/MetricsPollingService.cs new file mode 100644 index 0000000..0d2ae2f --- /dev/null +++ b/src/AmtocBots.Api/BackgroundServices/MetricsPollingService.cs @@ -0,0 +1,72 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Hubs; +using AmtocBots.Api.Services.Docker; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.BackgroundServices; + +public sealed class MetricsPollingService( + IServiceScopeFactory scopeFactory, + IHubContext hub, + IDockerService docker, + ILogger log) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + log.LogInformation("Metrics polling service started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await PollAndBroadcastAsync(stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + log.LogWarning(ex, "Error during metrics polling"); + } + + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } + } + + private async Task PollAndBroadcastAsync(CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + var running = await db.Instances + .Where(i => i.ContainerId != null && i.Status == "running") + .Select(i => new { i.Id, ContainerId = i.ContainerId! }) + .ToListAsync(ct); + + if (running.Count == 0) return; + + var stats = await docker.GetAllManagedStatsAsync( + running.Select(r => (r.Id, r.ContainerId)), ct); + + foreach (var stat in stats) + { + await hub.Clients.Group($"instance:{stat.InstanceId}") + .SendAsync("StatusUpdate", stat, ct); + } + + // Sync status changes back to DB + var statusMap = stats.ToDictionary(s => s.InstanceId, s => s.Status); + foreach (var inst in running) + { + if (statusMap.TryGetValue(inst.Id, out var newStatus)) + { + var entity = await db.Instances.FindAsync([inst.Id], ct); + if (entity is not null && entity.Status != newStatus) + { + entity.Status = newStatus; + entity.UpdatedAt = DateTimeOffset.UtcNow; + } + } + } + + await db.SaveChangesAsync(ct); + } +} diff --git a/src/AmtocBots.Api/BackgroundServices/ModelSwitchScheduler.cs b/src/AmtocBots.Api/BackgroundServices/ModelSwitchScheduler.cs new file mode 100644 index 0000000..02efa66 --- /dev/null +++ b/src/AmtocBots.Api/BackgroundServices/ModelSwitchScheduler.cs @@ -0,0 +1,69 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Services.Models; +using Microsoft.EntityFrameworkCore; +using NCrontab; + +namespace AmtocBots.Api.BackgroundServices; + +public sealed class ModelSwitchScheduler( + IServiceScopeFactory scopeFactory, + ILogger log) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + log.LogInformation("Model switch scheduler started"); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + await EvaluateAsync(stoppingToken); + } + catch (Exception ex) when (!stoppingToken.IsCancellationRequested) + { + log.LogWarning(ex, "Error in model switch scheduler"); + } + + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + } + } + + private async Task EvaluateAsync(CancellationToken ct) + { + using var scope = scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var switcher = scope.ServiceProvider.GetRequiredService(); + + var now = DateTime.UtcNow; + var cronRules = await db.SwitchRules + .Where(r => r.IsActive && r.RuleType == "cron" && r.CronExpression != null) + .ToListAsync(ct); + + foreach (var rule in cronRules) + { + try + { + var schedule = CrontabSchedule.Parse(rule.CronExpression!, new CrontabSchedule.ParseOptions { IncludingSeconds = false }); + var next = schedule.GetNextOccurrence(now.AddMinutes(-1)); + if (next <= now) + { + log.LogInformation("Cron rule triggered for instance {Id}: switch to {Model}", rule.InstanceId, rule.TargetModel); + await switcher.SwitchModelAsync(rule.InstanceId, rule.TargetModel, ct); + } + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to evaluate cron rule {Id}", rule.Id); + } + } + + // Also evaluate threshold rules for all running instances + var runningIds = await db.Instances + .Where(i => i.Status == "running") + .Select(i => i.Id) + .ToListAsync(ct); + + foreach (var id in runningIds) + await switcher.EvaluateThresholdRulesAsync(id, ct); + } +} diff --git a/src/AmtocBots.Api/BackgroundServices/QueueRetryWorker.cs b/src/AmtocBots.Api/BackgroundServices/QueueRetryWorker.cs new file mode 100644 index 0000000..789d314 --- /dev/null +++ b/src/AmtocBots.Api/BackgroundServices/QueueRetryWorker.cs @@ -0,0 +1,56 @@ +using AmtocBots.Api.Services.OpenClaw; +using AmtocBots.Api.Services.Queue; + +namespace AmtocBots.Api.BackgroundServices; + +public sealed class QueueRetryWorker( + RedisMessageQueueService queue, + IOpenClawClient openClaw, + ILogger log) : BackgroundService +{ + private static readonly TimeSpan[] Backoffs = [ + TimeSpan.FromSeconds(5), + TimeSpan.FromSeconds(15), + TimeSpan.FromSeconds(30), + TimeSpan.FromMinutes(2), + TimeSpan.FromMinutes(5), + ]; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + log.LogInformation("Queue retry worker started"); + + while (!stoppingToken.IsCancellationRequested) + { + // Move any ready delayed messages back to main queue + await queue.FlushReadyDelayedAsync(stoppingToken); + + var msg = await queue.DequeueAsync(stoppingToken); + if (msg is null) + { + await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken); + continue; + } + + try + { + await openClaw.RunAgentAsync(msg.BaseUrl, msg.BearerToken, + new AgentRequest(msg.Description, msg.Model), stoppingToken); + log.LogDebug("Retried queued message for instance {Id}", msg.InstanceId); + } + catch (HttpRequestException ex) when ((int?)ex.StatusCode == 429) + { + var delay = msg.RetryCount < Backoffs.Length + ? Backoffs[msg.RetryCount] + : TimeSpan.FromMinutes(10); + + log.LogWarning("Rate limited on instance {Id}, requeuing with {Delay}s delay", msg.InstanceId, delay.TotalSeconds); + await queue.RequeueWithDelayAsync(msg, delay, stoppingToken); + } + catch (Exception ex) + { + log.LogError(ex, "Failed to send queued message for instance {Id}", msg.InstanceId); + } + } + } +} diff --git a/src/AmtocBots.Api/Configuration/DockerOptions.cs b/src/AmtocBots.Api/Configuration/DockerOptions.cs new file mode 100644 index 0000000..0ad51bf --- /dev/null +++ b/src/AmtocBots.Api/Configuration/DockerOptions.cs @@ -0,0 +1,10 @@ +namespace AmtocBots.Api.Configuration; + +public sealed class DockerOptions +{ + public string SocketPath { get; set; } = "/var/run/docker.sock"; + public string OpenClawNetwork { get; set; } = "amtocbots_openclaw"; + public string OpenClawImage { get; set; } = "ghcr.io/openclaw/openclaw:latest"; + public int PortRangeStart { get; set; } = 18789; + public int PortRangeEnd { get; set; } = 19789; +} diff --git a/src/AmtocBots.Api/Configuration/EncryptionOptions.cs b/src/AmtocBots.Api/Configuration/EncryptionOptions.cs new file mode 100644 index 0000000..a43f0f5 --- /dev/null +++ b/src/AmtocBots.Api/Configuration/EncryptionOptions.cs @@ -0,0 +1,7 @@ +namespace AmtocBots.Api.Configuration; + +public sealed class EncryptionOptions +{ + /// Base64-encoded 32-byte AES-256 key. Generate with: openssl rand -base64 32 + public string Key { get; set; } = string.Empty; +} diff --git a/src/AmtocBots.Api/Configuration/KeycloakOptions.cs b/src/AmtocBots.Api/Configuration/KeycloakOptions.cs new file mode 100644 index 0000000..891cc5a --- /dev/null +++ b/src/AmtocBots.Api/Configuration/KeycloakOptions.cs @@ -0,0 +1,7 @@ +namespace AmtocBots.Api.Configuration; + +public sealed class KeycloakOptions +{ + public string Authority { get; set; } = string.Empty; + public string Audience { get; set; } = "amtocbots-api"; +} diff --git a/src/AmtocBots.Api/Configuration/OllamaOptions.cs b/src/AmtocBots.Api/Configuration/OllamaOptions.cs new file mode 100644 index 0000000..92028eb --- /dev/null +++ b/src/AmtocBots.Api/Configuration/OllamaOptions.cs @@ -0,0 +1,6 @@ +namespace AmtocBots.Api.Configuration; + +public sealed class OllamaOptions +{ + public string BaseUrl { get; set; } = "http://localhost:11434"; +} diff --git a/src/AmtocBots.Api/Controllers/ChatController.cs b/src/AmtocBots.Api/Controllers/ChatController.cs new file mode 100644 index 0000000..d0a3c44 --- /dev/null +++ b/src/AmtocBots.Api/Controllers/ChatController.cs @@ -0,0 +1,129 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.DTOs.Chat; +using AmtocBots.Api.Hubs; +using AmtocBots.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Controllers; + +[ApiController] +[Route("api/chat")] +[Authorize] +public sealed class ChatController( + AppDbContext db, + IHubContext hub) : ControllerBase +{ + [HttpGet("rooms")] + public async Task ListRooms(CancellationToken ct) + { + var rooms = await db.ChatRooms + .Select(r => new RoomSummaryDto(r.Id, r.Name, r.Description, r.IsGlobal)) + .ToListAsync(ct); + return Ok(rooms); + } + + [HttpPost("rooms")] + [Authorize(Roles = "admin,operator")] + public async Task CreateRoom([FromBody] CreateRoomRequest req, CancellationToken ct) + { + var room = new ChatRoom + { + Name = req.Name, + Description = req.Description, + IsGlobal = req.IsGlobal, + CreatedBy = GetUserId(), + }; + db.ChatRooms.Add(room); + await db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(GetMessages), new { id = room.Id }, room.Id); + } + + [HttpGet("rooms/{id:guid}/messages")] + public async Task GetMessages( + Guid id, [FromQuery] DateTimeOffset? before = null, [FromQuery] int limit = 50, CancellationToken ct = default) + { + var query = db.ChatMessages.Where(m => m.RoomId == id); + if (before.HasValue) query = query.Where(m => m.CreatedAt < before.Value); + + var messages = await query + .OrderByDescending(m => m.CreatedAt) + .Take(limit) + .OrderBy(m => m.CreatedAt) + .ToListAsync(ct); + + var userIds = messages.Where(m => m.SenderType == "user").Select(m => m.SenderId).Distinct().ToList(); + var users = await db.Users.Where(u => userIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.Username, ct); + + var botIds = messages.Where(m => m.SenderType == "bot").Select(m => m.SenderId).Distinct().ToList(); + var bots = await db.Instances.Where(i => botIds.Contains(i.Id)) + .ToDictionaryAsync(i => i.Id, i => i.Name, ct); + + var dtos = messages.Select(m => new MessageDto( + m.Id, m.RoomId, m.SenderType, m.SenderId, + m.SenderType == "user" ? users.GetValueOrDefault(m.SenderId, "Unknown") : bots.GetValueOrDefault(m.SenderId, "Bot"), + m.Content, m.Mentions, m.ReplyToId, m.CreatedAt, m.EditedAt)); + + return Ok(dtos); + } + + [HttpPost("rooms/{id:guid}/members")] + [Authorize(Roles = "admin,operator")] + public async Task AddMember(Guid id, [FromBody] AddMemberRequest req, CancellationToken ct) + { + var member = new ChatRoomMember { RoomId = id, MemberType = req.MemberType, MemberId = req.MemberId }; + db.ChatRoomMembers.Add(member); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("rooms/{id:guid}/bot-message")] + [AllowAnonymous] + public async Task BotMessage(Guid id, [FromBody] BotMessageRequest req, CancellationToken ct) + { + // Extract instance ID from route context — identify via API key + var authHeader = Request.Headers.Authorization.FirstOrDefault(); + if (authHeader is null || !authHeader.StartsWith("Bearer ")) return Unauthorized(); + var token = authHeader["Bearer ".Length..]; + + var instance = await db.Instances + .Where(i => i.ApiBearerTokenHash != null && db.Instances.Any(x => x.Id == i.Id)) + .ToListAsync(ct); + + var matched = instance.FirstOrDefault(i => + i.ApiBearerTokenHash is not null && BCrypt.Net.BCrypt.Verify(token, i.ApiBearerTokenHash)); + if (matched is null) return Unauthorized(); + + var msg = new ChatMessage + { + RoomId = id, + SenderType = "bot", + SenderId = matched.Id, + Content = req.Content, + ReplyToId = req.ReplyToId, + }; + db.ChatMessages.Add(msg); + await db.SaveChangesAsync(ct); + + await hub.Clients.Group($"room:{id}").SendAsync("MessageReceived", new + { + id = msg.Id, + roomId = id, + senderType = "bot", + senderId = matched.Id, + senderName = matched.Name, + content = req.Content, + replyToId = req.ReplyToId, + createdAt = msg.CreatedAt, + }, ct); + + return Ok(msg.Id); + } + + private Guid GetUserId() => Guid.Parse(User.FindFirst("sub")!.Value); +} + +public sealed record AddMemberRequest(string MemberType, Guid MemberId); diff --git a/src/AmtocBots.Api/Controllers/InstancesController.cs b/src/AmtocBots.Api/Controllers/InstancesController.cs new file mode 100644 index 0000000..f606c5b --- /dev/null +++ b/src/AmtocBots.Api/Controllers/InstancesController.cs @@ -0,0 +1,188 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.DTOs.Instances; +using AmtocBots.Api.Models; +using AmtocBots.Api.Services.Docker; +using AmtocBots.Api.Services.OpenClaw; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Controllers; + +[ApiController] +[Route("api/instances")] +[Authorize] +public sealed class InstancesController( + AppDbContext db, + IDockerService docker, + OpenClawConfigBuilder configBuilder, + ILogger log) : ControllerBase +{ + [HttpGet] + public async Task List(CancellationToken ct) + { + var items = await db.Instances + .OrderBy(i => i.Name) + .Select(i => new InstanceSummaryDto( + i.Id, i.Name, i.Description, i.Status, i.CurrentModel, + i.ContainerName, i.HostPort, i.CpuLimit, i.MemoryLimitMb, + i.CreatedAt, i.UpdatedAt)) + .ToListAsync(ct); + return Ok(items); + } + + [HttpGet("{id:guid}")] + public async Task Get(Guid id, CancellationToken ct) + { + var i = await db.Instances.FindAsync([id], ct); + if (i is null) return NotFound(); + return Ok(new InstanceDetailDto(i.Id, i.Name, i.Description, i.Status, i.CurrentModel, + i.ContainerName, i.ContainerId, i.HostPort, i.CpuLimit, i.MemoryLimitMb, + i.ConfigJson, i.CreatedAt, i.UpdatedAt)); + } + + [HttpPost] + [Authorize(Roles = "admin,operator")] + public async Task Create([FromBody] CreateInstanceRequest req, CancellationToken ct) + { + var userId = GetUserId(); + + // Allocate next port (serialized via DB transaction) + await using var tx = await db.Database.BeginTransactionAsync(ct); + var maxPort = await db.Instances.MaxAsync(i => (int?)i.HostPort, ct) ?? 18788; + var port = maxPort + 1; + + // Generate a plain-text bearer token (shown once) + hash for storage + var token = Convert.ToBase64String(System.Security.Cryptography.RandomNumberGenerator.GetBytes(32)); + var tokenHash = BCrypt.Net.BCrypt.HashPassword(token); + + var containerName = $"openclaw-{req.Name.ToLower().Replace(" ", "-")}"; + var instance = new BotInstance + { + Name = req.Name, + Description = req.Description, + CurrentModel = req.Model, + ContainerName = containerName, + HostPort = port, + CpuLimit = req.CpuLimit, + MemoryLimitMb = req.MemoryLimitMb, + ApiBearerTokenHash = tokenHash, + CreatedBy = userId, + }; + + db.Instances.Add(instance); + await db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + + log.LogInformation("Created instance {Name} (port {Port})", req.Name, port); + + return CreatedAtAction(nameof(Get), new { id = instance.Id }, + new InstanceTokenResponse(instance.Id, token)); + } + + [HttpPut("{id:guid}")] + [Authorize(Roles = "admin,operator")] + public async Task Update(Guid id, [FromBody] UpdateInstanceRequest req, CancellationToken ct) + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return NotFound(); + + if (req.Name is not null) instance.Name = req.Name; + if (req.Description is not null) instance.Description = req.Description; + if (req.Model is not null) instance.CurrentModel = req.Model; + if (req.CpuLimit.HasValue) instance.CpuLimit = req.CpuLimit; + if (req.MemoryLimitMb.HasValue) instance.MemoryLimitMb = req.MemoryLimitMb; + instance.UpdatedAt = DateTimeOffset.UtcNow; + + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("{id:guid}")] + [Authorize(Roles = "admin")] + public async Task Delete(Guid id, CancellationToken ct) + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return NotFound(); + + if (instance.ContainerId is not null) + { + try { await docker.RemoveContainerAsync(instance.ContainerId, force: true, ct); } + catch (Exception ex) { log.LogWarning(ex, "Failed to remove container for instance {Id}", id); } + } + + db.Instances.Remove(instance); + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/start")] + [Authorize(Roles = "admin,operator")] + public async Task Start(Guid id, CancellationToken ct) + { + var instance = await db.Instances.Include(i => i.ChannelConfigs).FirstOrDefaultAsync(i => i.Id == id, ct); + if (instance is null) return NotFound(); + + // Write config to volume first + var config = configBuilder.Build(instance, instance.ChannelConfigs, "placeholder"); + await docker.WriteConfigVolumeAsync(id, config, ct); + + var containerId = await docker.CreateAndStartContainerAsync(instance, ct); + instance.ContainerId = containerId; + instance.Status = "running"; + instance.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + return Ok(new { containerId }); + } + + [HttpPost("{id:guid}/stop")] + [Authorize(Roles = "admin,operator")] + public async Task Stop(Guid id, CancellationToken ct) + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return NotFound(); + if (instance.ContainerId is null) return BadRequest("Instance not running"); + + await docker.StopContainerAsync(instance.ContainerId, ct); + instance.Status = "stopped"; + instance.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/restart")] + [Authorize(Roles = "admin,operator")] + public async Task Restart(Guid id, CancellationToken ct) + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return NotFound(); + if (instance.ContainerId is null) return BadRequest("Instance not running"); + + await docker.RestartContainerAsync(instance.ContainerId, ct); + return NoContent(); + } + + [HttpGet("{id:guid}/logs")] + public async Task Logs(Guid id, [FromQuery] int tail = 200, CancellationToken ct = default) + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return NotFound(); + if (instance.ContainerId is null) return BadRequest("Instance not running"); + + var logs = await docker.GetLogsAsync(instance.ContainerId, tail, ct); + return Content(logs, "text/plain"); + } + + [HttpGet("{id:guid}/config")] + public async Task GetConfig(Guid id, CancellationToken ct) + { + var instance = await db.Instances.Include(i => i.ChannelConfigs).FirstOrDefaultAsync(i => i.Id == id, ct); + if (instance is null) return NotFound(); + var config = configBuilder.Build(instance, instance.ChannelConfigs, "[redacted]"); + return Content(config, "text/plain"); + } + + private Guid GetUserId() => + Guid.Parse(User.FindFirst("sub")!.Value); +} diff --git a/src/AmtocBots.Api/Controllers/KanbanController.cs b/src/AmtocBots.Api/Controllers/KanbanController.cs new file mode 100644 index 0000000..114c62e --- /dev/null +++ b/src/AmtocBots.Api/Controllers/KanbanController.cs @@ -0,0 +1,151 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.DTOs.Kanban; +using AmtocBots.Api.Hubs; +using AmtocBots.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Controllers; + +[ApiController] +[Route("api/kanban")] +[Authorize] +public sealed class KanbanController( + AppDbContext db, + IHubContext hub) : ControllerBase +{ + [HttpGet("boards")] + public async Task ListBoards(CancellationToken ct) + { + var boards = await db.KanbanBoards + .Select(b => new BoardSummaryDto(b.Id, b.Name, b.Description, b.CreatedAt)) + .ToListAsync(ct); + return Ok(boards); + } + + [HttpPost("boards")] + [Authorize(Roles = "admin,operator")] + public async Task CreateBoard([FromBody] CreateBoardRequest req, CancellationToken ct) + { + var board = new KanbanBoard { Name = req.Name, Description = req.Description, CreatedBy = GetUserId() }; + // Default columns + board.Columns.Add(new KanbanColumn { Name = "Backlog", Position = 0 }); + board.Columns.Add(new KanbanColumn { Name = "In Progress", Position = 1, Color = "#3b82f6" }); + board.Columns.Add(new KanbanColumn { Name = "Review", Position = 2, Color = "#f59e0b" }); + board.Columns.Add(new KanbanColumn { Name = "Done", Position = 3, Color = "#10b981" }); + db.KanbanBoards.Add(board); + await db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(GetBoard), new { id = board.Id }, board.Id); + } + + [HttpGet("boards/{id:guid}")] + public async Task GetBoard(Guid id, CancellationToken ct) + { + var board = await db.KanbanBoards + .Include(b => b.Columns.OrderBy(c => c.Position)) + .ThenInclude(c => c.Cards.OrderBy(k => k.Position)) + .ThenInclude(k => k.AssignedInstance) + .FirstOrDefaultAsync(b => b.Id == id, ct); + if (board is null) return NotFound(); + + return Ok(new BoardDetailDto(board.Id, board.Name, board.Description, + board.Columns.Select(c => new ColumnDto(c.Id, c.Name, c.Position, c.Color, c.WipLimit, + c.Cards.Select(k => MapCard(k)).ToList())).ToList())); + } + + [HttpPost("boards/{id:guid}/cards")] + public async Task CreateCard(Guid id, [FromBody] CreateCardRequest req, CancellationToken ct) + { + var firstCol = await db.KanbanColumns + .Where(c => c.BoardId == id) + .OrderBy(c => c.Position) + .FirstOrDefaultAsync(ct); + if (firstCol is null) return BadRequest("Board has no columns"); + + var maxPos = await db.KanbanCards.Where(k => k.ColumnId == firstCol.Id).MaxAsync(k => (int?)k.Position, ct) ?? -1; + var card = new KanbanCard + { + ColumnId = firstCol.Id, + BoardId = id, + Title = req.Title, + Description = req.Description, + Priority = req.Priority, + Labels = req.Labels ?? [], + DueDate = req.DueDate, + Position = maxPos + 1, + AssignedInstanceId = req.AssignedInstanceId, + AssignedUserId = req.AssignedUserId, + CreatedByType = "human", + CreatedById = GetUserId(), + }; + db.KanbanCards.Add(card); + await db.SaveChangesAsync(ct); + + await hub.Clients.Group($"board:{id}").SendAsync("CardCreated", MapCard(card), ct); + return CreatedAtAction(nameof(GetBoard), new { id }, card.Id); + } + + [HttpPatch("cards/{cardId:guid}/move")] + public async Task MoveCard(Guid cardId, [FromBody] MoveCardRequest req, CancellationToken ct) + { + var card = await db.KanbanCards.FindAsync([cardId], ct); + if (card is null) return NotFound(); + + card.ColumnId = req.TargetColumnId; + card.Position = req.Position; + card.UpdatedAt = DateTimeOffset.UtcNow; + await db.SaveChangesAsync(ct); + + await hub.Clients.Group($"board:{card.BoardId}").SendAsync("CardMoved", + new { cardId, targetColumnId = req.TargetColumnId, position = req.Position }, ct); + return NoContent(); + } + + [HttpPost("bot-webhook")] + [AllowAnonymous] // auth handled manually via API key + public async Task BotWebhook([FromBody] BotWebhookCardRequest req, CancellationToken ct) + { + var instance = await db.Instances + .Where(i => i.Id == req.InstanceId) + .Select(i => new { i.ApiBearerTokenHash }) + .FirstOrDefaultAsync(ct); + + if (instance?.ApiBearerTokenHash is null || !BCrypt.Net.BCrypt.Verify(req.ApiKey, instance.ApiBearerTokenHash)) + return Unauthorized(); + + var colId = req.ColumnId ?? (await db.KanbanColumns + .Where(c => c.BoardId == req.BoardId) + .OrderBy(c => c.Position) + .Select(c => c.Id) + .FirstOrDefaultAsync(ct)); + + if (colId == default) return BadRequest("Board not found or has no columns"); + + var maxPos = await db.KanbanCards.Where(k => k.ColumnId == colId).MaxAsync(k => (int?)k.Position, ct) ?? -1; + var card = new KanbanCard + { + ColumnId = colId, + BoardId = req.BoardId, + Title = req.Title, + Description = req.Description, + Priority = req.Priority, + Position = maxPos + 1, + AssignedInstanceId = req.InstanceId, + CreatedByType = "bot", + CreatedById = req.InstanceId, + }; + db.KanbanCards.Add(card); + await db.SaveChangesAsync(ct); + + await hub.Clients.Group($"board:{req.BoardId}").SendAsync("CardCreated", MapCard(card), ct); + return Ok(card.Id); + } + + private static CardDto MapCard(KanbanCard k) => new( + k.Id, k.ColumnId, k.Title, k.Description, k.Priority, k.Labels, k.DueDate, + k.Position, k.AssignedInstanceId, k.AssignedUserId, k.CreatedByType, k.CreatedById, k.CreatedAt); + + private Guid GetUserId() => Guid.Parse(User.FindFirst("sub")!.Value); +} diff --git a/src/AmtocBots.Api/Controllers/ModelsController.cs b/src/AmtocBots.Api/Controllers/ModelsController.cs new file mode 100644 index 0000000..a2326af --- /dev/null +++ b/src/AmtocBots.Api/Controllers/ModelsController.cs @@ -0,0 +1,141 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.DTOs.Models; +using AmtocBots.Api.Models; +using AmtocBots.Api.Services.Models; +using AmtocBots.Api.Services.Ollama; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Controllers; + +[ApiController] +[Route("api")] +[Authorize] +public sealed class ModelsController( + AppDbContext db, + IModelSwitchingService switcher, + ITokenTracker tracker, + IOllamaService ollama) : ControllerBase +{ + private static readonly string[] BuiltInProviders = + [ + "anthropic/claude-opus-4-6", + "anthropic/claude-sonnet-4-6", + "anthropic/claude-haiku-4-5-20251001", + "openai/gpt-4o", + "openai/gpt-4o-mini", + "google/gemini-2.0-flash", + "openrouter/auto", + ]; + + [HttpGet("models/available")] + public async Task Available(CancellationToken ct) + { + var ollamaModels = await ollama.ListModelsAsync(ct); + var all = BuiltInProviders + .Select(m => new { id = m, provider = m.Split('/')[0], local = false }) + .Concat(ollamaModels.Select(m => new { id = $"ollama/{m.Name}", provider = "ollama", local = true })); + return Ok(all); + } + + [HttpGet("instances/{id:guid}/model")] + public async Task GetModel(Guid id, CancellationToken ct) + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return NotFound(); + return Ok(new { model = instance.CurrentModel }); + } + + [HttpPut("instances/{id:guid}/model")] + [Authorize(Roles = "admin,operator")] + public async Task SwitchModel(Guid id, [FromBody] SwitchModelRequest req, CancellationToken ct) + { + await switcher.SwitchModelAsync(id, req.Model, ct); + return NoContent(); + } + + [HttpGet("instances/{id:guid}/token-usage")] + public async Task InstanceUsage(Guid id, [FromQuery] int days = 7, CancellationToken ct = default) + { + var from = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days)); + var records = await db.TokenUsage + .Where(r => r.InstanceId == id && r.UsageDate >= from) + .OrderBy(r => r.UsageDate) + .Select(r => new TokenUsageSummaryDto(r.InstanceId, r.Model, r.UsageDate, + r.PromptTokens, r.CompletionTokens, r.TotalTokens, r.EstimatedCostUsd)) + .ToListAsync(ct); + return Ok(records); + } + + [HttpGet("token-usage/summary")] + public async Task Summary([FromQuery] int days = 7, CancellationToken ct = default) + { + var from = DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-days)); + var records = await db.TokenUsage + .Where(r => r.UsageDate >= from) + .OrderBy(r => r.UsageDate) + .Select(r => new TokenUsageSummaryDto(r.InstanceId, r.Model, r.UsageDate, + r.PromptTokens, r.CompletionTokens, r.TotalTokens, r.EstimatedCostUsd)) + .ToListAsync(ct); + return Ok(records); + } + + [HttpGet("instances/{id:guid}/switch-rules")] + public async Task ListRules(Guid id, CancellationToken ct) + { + var rules = await db.SwitchRules + .Where(r => r.InstanceId == id) + .OrderByDescending(r => r.Priority) + .Select(r => new SwitchRuleDto(r.Id, r.RuleType, r.TriggerModel, r.ThresholdPct, + r.CronExpression, r.TargetModel, r.IsActive, r.Priority)) + .ToListAsync(ct); + return Ok(rules); + } + + [HttpPost("instances/{id:guid}/switch-rules")] + [Authorize(Roles = "admin,operator")] + public async Task CreateRule(Guid id, [FromBody] CreateSwitchRuleRequest req, CancellationToken ct) + { + var rule = new ModelSwitchRule + { + InstanceId = id, + RuleType = req.RuleType, + TriggerModel = req.TriggerModel, + ThresholdPct = req.ThresholdPct, + CronExpression = req.CronExpression, + TargetModel = req.TargetModel, + Priority = req.Priority, + }; + db.SwitchRules.Add(rule); + await db.SaveChangesAsync(ct); + return CreatedAtAction(nameof(ListRules), new { id }, rule.Id); + } + + [HttpPut("instances/{id:guid}/switch-rules/{ruleId:guid}")] + [Authorize(Roles = "admin,operator")] + public async Task UpdateRule(Guid id, Guid ruleId, [FromBody] CreateSwitchRuleRequest req, CancellationToken ct) + { + var rule = await db.SwitchRules.FirstOrDefaultAsync(r => r.Id == ruleId && r.InstanceId == id, ct); + if (rule is null) return NotFound(); + rule.RuleType = req.RuleType; + rule.TriggerModel = req.TriggerModel; + rule.ThresholdPct = req.ThresholdPct; + rule.CronExpression = req.CronExpression; + rule.TargetModel = req.TargetModel; + rule.Priority = req.Priority; + await db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpDelete("instances/{id:guid}/switch-rules/{ruleId:guid}")] + [Authorize(Roles = "admin,operator")] + public async Task DeleteRule(Guid id, Guid ruleId, CancellationToken ct) + { + var rule = await db.SwitchRules.FirstOrDefaultAsync(r => r.Id == ruleId && r.InstanceId == id, ct); + if (rule is null) return NotFound(); + db.SwitchRules.Remove(rule); + await db.SaveChangesAsync(ct); + return NoContent(); + } +} diff --git a/src/AmtocBots.Api/Controllers/WebhookController.cs b/src/AmtocBots.Api/Controllers/WebhookController.cs new file mode 100644 index 0000000..7b13255 --- /dev/null +++ b/src/AmtocBots.Api/Controllers/WebhookController.cs @@ -0,0 +1,69 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Services.Models; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System.Text.Json.Serialization; + +namespace AmtocBots.Api.Controllers; + +/// +/// Receives callbacks from OpenClaw instances (or the token-tracking sidecar proxy). +/// These endpoints use instance API key auth, not JWT. +/// +[ApiController] +[Route("api/webhooks/openclaw")] +public sealed class WebhookController( + AppDbContext db, + ITokenTracker tracker, + ILogger log) : ControllerBase +{ + [HttpPost("{instanceId:guid}/token-usage")] + public async Task TokenUsage(Guid instanceId, [FromBody] TokenUsagePayload payload, CancellationToken ct) + { + if (!await ValidateApiKey(instanceId, ct)) return Unauthorized(); + + await tracker.RecordAsync(new TokenUsageSample( + instanceId, + payload.Model, + payload.PromptTokens, + payload.CompletionTokens, + payload.CostUsd), ct); + + return Ok(); + } + + [HttpPost("{instanceId:guid}/event")] + public async Task Event(Guid instanceId, [FromBody] EventPayload payload, CancellationToken ct) + { + if (!await ValidateApiKey(instanceId, ct)) return Unauthorized(); + + log.LogInformation("OpenClaw event from instance {Id}: {Type}", instanceId, payload.Type); + // Future: handle specific events (session reset, error, etc.) + return Ok(); + } + + private async Task ValidateApiKey(Guid instanceId, CancellationToken ct) + { + var authHeader = Request.Headers.Authorization.FirstOrDefault(); + if (authHeader is null || !authHeader.StartsWith("Bearer ")) return false; + var token = authHeader["Bearer ".Length..]; + + var instance = await db.Instances + .Where(i => i.Id == instanceId) + .Select(i => new { i.ApiBearerTokenHash }) + .FirstOrDefaultAsync(ct); + + return instance?.ApiBearerTokenHash is not null + && BCrypt.Net.BCrypt.Verify(token, instance.ApiBearerTokenHash); + } +} + +public sealed record TokenUsagePayload( + [property: JsonPropertyName("model")] string Model, + [property: JsonPropertyName("prompt_tokens")] long PromptTokens, + [property: JsonPropertyName("completion_tokens")] long CompletionTokens, + [property: JsonPropertyName("cost_usd")] decimal? CostUsd); + +public sealed record EventPayload( + [property: JsonPropertyName("type")] string Type, + [property: JsonPropertyName("data")] object? Data); diff --git a/src/AmtocBots.Api/DTOs/Chat/ChatDtos.cs b/src/AmtocBots.Api/DTOs/Chat/ChatDtos.cs new file mode 100644 index 0000000..5eab3cf --- /dev/null +++ b/src/AmtocBots.Api/DTOs/Chat/ChatDtos.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace AmtocBots.Api.DTOs.Chat; + +public sealed record RoomSummaryDto(Guid Id, string Name, string? Description, bool IsGlobal); + +public sealed record MessageDto( + Guid Id, + Guid RoomId, + string SenderType, + Guid SenderId, + string SenderName, + string Content, + Guid[] Mentions, + Guid? ReplyToId, + DateTimeOffset CreatedAt, + DateTimeOffset? EditedAt); + +public sealed record CreateRoomRequest([Required, MaxLength(100)] string Name, string? Description, bool IsGlobal = false); + +public sealed record BotMessageRequest( + [Required] string ApiKey, + [Required] string Content, + Guid? ReplyToId = null); diff --git a/src/AmtocBots.Api/DTOs/Instances/InstanceDtos.cs b/src/AmtocBots.Api/DTOs/Instances/InstanceDtos.cs new file mode 100644 index 0000000..3ceda5f --- /dev/null +++ b/src/AmtocBots.Api/DTOs/Instances/InstanceDtos.cs @@ -0,0 +1,49 @@ +using System.ComponentModel.DataAnnotations; + +namespace AmtocBots.Api.DTOs.Instances; + +public sealed record InstanceSummaryDto( + Guid Id, + string Name, + string? Description, + string Status, + string CurrentModel, + string ContainerName, + int HostPort, + decimal? CpuLimit, + int? MemoryLimitMb, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record InstanceDetailDto( + Guid Id, + string Name, + string? Description, + string Status, + string CurrentModel, + string ContainerName, + string? ContainerId, + int HostPort, + decimal? CpuLimit, + int? MemoryLimitMb, + string? ConfigJson, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +public sealed record CreateInstanceRequest( + [Required, MaxLength(100)] string Name, + string? Description, + string Model = "anthropic/claude-sonnet-4-6", + decimal? CpuLimit = null, + int? MemoryLimitMb = null); + +public sealed record UpdateInstanceRequest( + string? Name, + string? Description, + string? Model, + decimal? CpuLimit, + int? MemoryLimitMb); + +public sealed record InstanceTokenResponse( + Guid InstanceId, + string Token); // shown once on creation diff --git a/src/AmtocBots.Api/DTOs/Kanban/KanbanDtos.cs b/src/AmtocBots.Api/DTOs/Kanban/KanbanDtos.cs new file mode 100644 index 0000000..4b58b1b --- /dev/null +++ b/src/AmtocBots.Api/DTOs/Kanban/KanbanDtos.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; + +namespace AmtocBots.Api.DTOs.Kanban; + +public sealed record BoardSummaryDto(Guid Id, string Name, string? Description, DateTimeOffset CreatedAt); + +public sealed record BoardDetailDto(Guid Id, string Name, string? Description, List Columns); + +public sealed record ColumnDto(Guid Id, string Name, int Position, string? Color, int? WipLimit, List Cards); + +public sealed record CardDto( + Guid Id, + Guid ColumnId, + string Title, + string? Description, + string? Priority, + string[] Labels, + DateTimeOffset? DueDate, + int Position, + Guid? AssignedInstanceId, + Guid? AssignedUserId, + string CreatedByType, + Guid CreatedById, + DateTimeOffset CreatedAt); + +public sealed record CreateBoardRequest([Required, MaxLength(100)] string Name, string? Description); + +public sealed record CreateColumnRequest([Required] string Name, int Position, string? Color, int? WipLimit); + +public sealed record CreateCardRequest( + [Required, MaxLength(255)] string Title, + string? Description, + string? Priority, + string[]? Labels, + DateTimeOffset? DueDate, + Guid? AssignedInstanceId, + Guid? AssignedUserId); + +public sealed record MoveCardRequest(Guid TargetColumnId, int Position); + +public sealed record BotWebhookCardRequest( + [Required] string ApiKey, + [Required] Guid BoardId, + Guid? ColumnId, // defaults to first column if omitted + [Required, MaxLength(255)] string Title, + string? Description, + string? Priority, + Guid InstanceId); diff --git a/src/AmtocBots.Api/DTOs/Models/ModelDtos.cs b/src/AmtocBots.Api/DTOs/Models/ModelDtos.cs new file mode 100644 index 0000000..aa27a56 --- /dev/null +++ b/src/AmtocBots.Api/DTOs/Models/ModelDtos.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace AmtocBots.Api.DTOs.Models; + +public sealed record SwitchRuleDto( + Guid Id, + string RuleType, + string? TriggerModel, + int? ThresholdPct, + string? CronExpression, + string TargetModel, + bool IsActive, + int Priority); + +public sealed record CreateSwitchRuleRequest( + [Required] string RuleType, + string? TriggerModel, + int? ThresholdPct, + string? CronExpression, + [Required] string TargetModel, + int Priority = 0); + +public sealed record TokenUsageSummaryDto( + Guid InstanceId, + string Model, + DateOnly Date, + long PromptTokens, + long CompletionTokens, + long TotalTokens, + decimal? EstimatedCostUsd); + +public sealed record SwitchModelRequest([Required] string Model); diff --git a/src/AmtocBots.Api/Dockerfile b/src/AmtocBots.Api/Dockerfile new file mode 100644 index 0000000..dd48dd4 --- /dev/null +++ b/src/AmtocBots.Api/Dockerfile @@ -0,0 +1,12 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY *.csproj . +RUN dotnet restore +COPY . . +RUN dotnet publish -c Release -o /app/publish + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "AmtocBots.Api.dll"] diff --git a/src/AmtocBots.Api/Endpoints/ChannelEndpoints.cs b/src/AmtocBots.Api/Endpoints/ChannelEndpoints.cs new file mode 100644 index 0000000..609a130 --- /dev/null +++ b/src/AmtocBots.Api/Endpoints/ChannelEndpoints.cs @@ -0,0 +1,84 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Models; +using AmtocBots.Api.Services.Docker; +using AmtocBots.Api.Services.OpenClaw; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Endpoints; + +public static class ChannelEndpoints +{ + public static RouteGroupBuilder MapChannelEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/instances/{id:guid}/channels", async (Guid id, AppDbContext db, CancellationToken ct) => + { + var configs = await db.ChannelConfigs + .Where(c => c.InstanceId == id) + .Select(c => new { c.Id, c.ChannelType, c.IsEnabled, c.UpdatedAt }) + .ToListAsync(ct); + return Results.Ok(configs); + }); + + group.MapPut("/instances/{id:guid}/channels/{type}", async ( + Guid id, string type, + [FromBody] UpsertChannelRequest req, + AppDbContext db, + IDockerService docker, + OpenClawConfigBuilder configBuilder, + CancellationToken ct) => + { + var existing = await db.ChannelConfigs + .FirstOrDefaultAsync(c => c.InstanceId == id && c.ChannelType == type, ct); + + if (existing is null) + { + db.ChannelConfigs.Add(new ChannelConfig + { + InstanceId = id, ChannelType = type, + IsEnabled = req.IsEnabled, ConfigJson = req.ConfigJson, + }); + } + else + { + existing.IsEnabled = req.IsEnabled; + existing.ConfigJson = req.ConfigJson; + existing.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(ct); + + // Rebuild and write config, then restart if running + var instance = await db.Instances.Include(i => i.ChannelConfigs).FirstOrDefaultAsync(i => i.Id == id, ct); + if (instance is not null) + { + var config = configBuilder.Build(instance, instance.ChannelConfigs, "placeholder"); + await docker.WriteConfigVolumeAsync(id, config, ct); + if (instance.ContainerId is not null && instance.Status == "running") + await docker.RestartContainerAsync(instance.ContainerId, ct); + } + + return Results.NoContent(); + }); + + group.MapGet("/instances/{id:guid}/channels/whatsapp/qr", async ( + Guid id, AppDbContext db, + IOpenClawClient openClaw, CancellationToken ct) => + { + var instance = await db.Instances.FindAsync([id], ct); + if (instance is null) return Results.NotFound(); + if (instance.ContainerId is null) return Results.BadRequest("Instance not running"); + + // Proxy QR from container on openclaw network + var baseUrl = $"http://{instance.ContainerName}:18789"; + var qr = await openClaw.GetWhatsAppQrAsync(baseUrl, string.Empty, ct); + if (qr is null) return Results.NotFound("QR not available yet"); + + return Results.File(qr.ImageBytes, qr.ContentType); + }); + + return group; + } +} + +public sealed record UpsertChannelRequest(bool IsEnabled, string ConfigJson); diff --git a/src/AmtocBots.Api/Endpoints/HealthEndpoints.cs b/src/AmtocBots.Api/Endpoints/HealthEndpoints.cs new file mode 100644 index 0000000..5bb15ef --- /dev/null +++ b/src/AmtocBots.Api/Endpoints/HealthEndpoints.cs @@ -0,0 +1,10 @@ +namespace AmtocBots.Api.Endpoints; + +public static class HealthEndpoints +{ + public static RouteGroupBuilder MapHealthEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/", () => Results.Ok(new { status = "healthy", utc = DateTimeOffset.UtcNow })); + return group; + } +} diff --git a/src/AmtocBots.Api/Endpoints/OllamaEndpoints.cs b/src/AmtocBots.Api/Endpoints/OllamaEndpoints.cs new file mode 100644 index 0000000..eebfdae --- /dev/null +++ b/src/AmtocBots.Api/Endpoints/OllamaEndpoints.cs @@ -0,0 +1,31 @@ +using AmtocBots.Api.Services.Ollama; + +namespace AmtocBots.Api.Endpoints; + +public static class OllamaEndpoints +{ + public static RouteGroupBuilder MapOllamaEndpoints(this RouteGroupBuilder group) + { + group.MapGet("/models", async (IOllamaService ollama, CancellationToken ct) => + { + var models = await ollama.ListModelsAsync(ct); + return Results.Ok(models); + }); + + group.MapPost("/pull", async (PullModelRequest req, IOllamaService ollama, CancellationToken ct) => + { + await ollama.PullModelAsync(req.Model, ct); + return Results.Accepted(); + }); + + group.MapGet("/status", async (IOllamaService ollama, CancellationToken ct) => + { + var healthy = await ollama.IsHealthyAsync(ct); + return Results.Ok(new { healthy }); + }); + + return group; + } +} + +public sealed record PullModelRequest(string Model); diff --git a/src/AmtocBots.Api/Hubs/ChatHub.cs b/src/AmtocBots.Api/Hubs/ChatHub.cs new file mode 100644 index 0000000..208c080 --- /dev/null +++ b/src/AmtocBots.Api/Hubs/ChatHub.cs @@ -0,0 +1,63 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Hubs; + +[Authorize] +public sealed class ChatHub(AppDbContext db) : Hub +{ + public async Task JoinRoom(string roomId) + { + await Groups.AddToGroupAsync(Context.ConnectionId, $"room:{roomId}"); + await Clients.Group($"room:{roomId}") + .SendAsync("UserJoined", GetUserId(), GetUsername()); + } + + public async Task LeaveRoom(string roomId) + { + await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"room:{roomId}"); + await Clients.Group($"room:{roomId}") + .SendAsync("UserLeft", GetUserId(), GetUsername()); + } + + public async Task SendMessage(string roomId, string content, string? replyToId = null) + { + var userId = GetUserId(); + var msg = new ChatMessage + { + RoomId = Guid.Parse(roomId), + SenderType = "user", + SenderId = userId, + Content = content, + ReplyToId = replyToId is null ? null : Guid.Parse(replyToId), + }; + + db.ChatMessages.Add(msg); + await db.SaveChangesAsync(); + + await Clients.Group($"room:{roomId}").SendAsync("MessageReceived", new + { + id = msg.Id, + roomId, + senderType = "user", + senderId = userId, + senderName = GetUsername(), + content, + replyToId, + createdAt = msg.CreatedAt, + }); + } + + public Task SendTyping(string roomId) => + Clients.OthersInGroup($"room:{roomId}") + .SendAsync("UserTyping", GetUserId(), GetUsername()); + + private Guid GetUserId() => + Guid.Parse(Context.User!.FindFirst("sub")!.Value); + + private string GetUsername() => + Context.User!.FindFirst("preferred_username")?.Value ?? "Unknown"; +} diff --git a/src/AmtocBots.Api/Hubs/InstanceHub.cs b/src/AmtocBots.Api/Hubs/InstanceHub.cs new file mode 100644 index 0000000..201ca83 --- /dev/null +++ b/src/AmtocBots.Api/Hubs/InstanceHub.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace AmtocBots.Api.Hubs; + +[Authorize] +public sealed class InstanceHub : Hub +{ + public Task SubscribeToInstance(string instanceId) => + Groups.AddToGroupAsync(Context.ConnectionId, $"instance:{instanceId}"); + + public Task UnsubscribeFromInstance(string instanceId) => + Groups.RemoveFromGroupAsync(Context.ConnectionId, $"instance:{instanceId}"); +} diff --git a/src/AmtocBots.Api/Hubs/KanbanHub.cs b/src/AmtocBots.Api/Hubs/KanbanHub.cs new file mode 100644 index 0000000..6f0414f --- /dev/null +++ b/src/AmtocBots.Api/Hubs/KanbanHub.cs @@ -0,0 +1,14 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; + +namespace AmtocBots.Api.Hubs; + +[Authorize] +public sealed class KanbanHub : Hub +{ + public Task JoinBoard(string boardId) => + Groups.AddToGroupAsync(Context.ConnectionId, $"board:{boardId}"); + + public Task LeaveBoard(string boardId) => + Groups.RemoveFromGroupAsync(Context.ConnectionId, $"board:{boardId}"); +} diff --git a/src/AmtocBots.Api/Models/AppUser.cs b/src/AmtocBots.Api/Models/AppUser.cs new file mode 100644 index 0000000..90f27e8 --- /dev/null +++ b/src/AmtocBots.Api/Models/AppUser.cs @@ -0,0 +1,14 @@ +namespace AmtocBots.Api.Models; + +public sealed class AppUser +{ + /// Matches Keycloak 'sub' claim. + public Guid Id { get; set; } + public string Username { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Role { get; set; } = "viewer"; // admin | operator | viewer + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastSeen { get; set; } + + public ICollection CreatedInstances { get; set; } = []; +} diff --git a/src/AmtocBots.Api/Models/BotInstance.cs b/src/AmtocBots.Api/Models/BotInstance.cs new file mode 100644 index 0000000..ca22896 --- /dev/null +++ b/src/AmtocBots.Api/Models/BotInstance.cs @@ -0,0 +1,38 @@ +namespace AmtocBots.Api.Models; + +public sealed class BotInstance +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + + // Docker + public string? ContainerId { get; set; } + public string ContainerName { get; set; } = string.Empty; + public int HostPort { get; set; } + public string Status { get; set; } = "stopped"; // stopped | starting | running | error + + // Model + public string CurrentModel { get; set; } = "anthropic/claude-sonnet-4-6"; + + // Resource limits + public decimal? CpuLimit { get; set; } + public int? MemoryLimitMb { get; set; } + + // Stored as JSON5 text; built from ChannelConfigs + model by OpenClawConfigBuilder + public string? ConfigJson { get; set; } + + // Bcrypt hash of the bearer token issued to this instance for /hooks/* calls + public string? ApiBearerTokenHash { get; set; } + + public Guid CreatedBy { get; set; } + public AppUser? Creator { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection ChannelConfigs { get; set; } = []; + public ICollection SwitchRules { get; set; } = []; + public ICollection TokenUsage { get; set; } = []; + public ICollection Learnings { get; set; } = []; +} diff --git a/src/AmtocBots.Api/Models/BotLearning.cs b/src/AmtocBots.Api/Models/BotLearning.cs new file mode 100644 index 0000000..e2afa3d --- /dev/null +++ b/src/AmtocBots.Api/Models/BotLearning.cs @@ -0,0 +1,18 @@ +using Pgvector; + +namespace AmtocBots.Api.Models; + +public sealed class BotLearning +{ + public Guid Id { get; set; } + public Guid SourceInstanceId { get; set; } + public BotInstance? SourceInstance { get; set; } + + public string Content { get; set; } = string.Empty; + + /// 1536-dimension vector embedding (OpenAI/Ollama compatible). + public Vector? Embedding { get; set; } + + public string[] Tags { get; set; } = []; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/AmtocBots.Api/Models/ChannelConfig.cs b/src/AmtocBots.Api/Models/ChannelConfig.cs new file mode 100644 index 0000000..6cbd637 --- /dev/null +++ b/src/AmtocBots.Api/Models/ChannelConfig.cs @@ -0,0 +1,21 @@ +namespace AmtocBots.Api.Models; + +public sealed class ChannelConfig +{ + public Guid Id { get; set; } + public Guid InstanceId { get; set; } + public BotInstance? Instance { get; set; } + + /// telegram | whatsapp | discord | slack + public string ChannelType { get; set; } = string.Empty; + public bool IsEnabled { get; set; } + + /// + /// Channel-specific fields as JSON. Sensitive tokens are AES-256 encrypted at rest + /// via EF Core value converter (see AppDbContext). + /// + public string ConfigJson { get; set; } = "{}"; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/AmtocBots.Api/Models/ChatRoom.cs b/src/AmtocBots.Api/Models/ChatRoom.cs new file mode 100644 index 0000000..b79668b --- /dev/null +++ b/src/AmtocBots.Api/Models/ChatRoom.cs @@ -0,0 +1,44 @@ +namespace AmtocBots.Api.Models; + +public sealed class ChatRoom +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public bool IsGlobal { get; set; } + public Guid CreatedBy { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Members { get; set; } = []; + public ICollection Messages { get; set; } = []; +} + +public sealed class ChatRoomMember +{ + public Guid RoomId { get; set; } + public ChatRoom? Room { get; set; } + + /// user | bot + public string MemberType { get; set; } = "user"; + public Guid MemberId { get; set; } + public DateTimeOffset JoinedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public sealed class ChatMessage +{ + public Guid Id { get; set; } + public Guid RoomId { get; set; } + public ChatRoom? Room { get; set; } + + /// user | bot + public string SenderType { get; set; } = "user"; + public Guid SenderId { get; set; } + + public string Content { get; set; } = string.Empty; + public Guid[] Mentions { get; set; } = []; + public Guid? ReplyToId { get; set; } + public ChatMessage? ReplyTo { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? EditedAt { get; set; } +} diff --git a/src/AmtocBots.Api/Models/KanbanBoard.cs b/src/AmtocBots.Api/Models/KanbanBoard.cs new file mode 100644 index 0000000..287746a --- /dev/null +++ b/src/AmtocBots.Api/Models/KanbanBoard.cs @@ -0,0 +1,51 @@ +namespace AmtocBots.Api.Models; + +public sealed class KanbanBoard +{ + public Guid Id { get; set; } + public string Name { get; set; } = string.Empty; + public string? Description { get; set; } + public Guid CreatedBy { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + + public ICollection Columns { get; set; } = []; +} + +public sealed class KanbanColumn +{ + public Guid Id { get; set; } + public Guid BoardId { get; set; } + public KanbanBoard? Board { get; set; } + public string Name { get; set; } = string.Empty; + public int Position { get; set; } + public string? Color { get; set; } + public int? WipLimit { get; set; } + + public ICollection Cards { get; set; } = []; +} + +public sealed class KanbanCard +{ + public Guid Id { get; set; } + public Guid ColumnId { get; set; } + public KanbanColumn? Column { get; set; } + public Guid BoardId { get; set; } + + public string Title { get; set; } = string.Empty; + public string? Description { get; set; } + public string? Priority { get; set; } // low | medium | high | critical + public string[] Labels { get; set; } = []; + public DateTimeOffset? DueDate { get; set; } + public int Position { get; set; } + + public Guid? AssignedInstanceId { get; set; } + public BotInstance? AssignedInstance { get; set; } + public Guid? AssignedUserId { get; set; } + + /// human | bot + public string CreatedByType { get; set; } = "human"; + public Guid CreatedById { get; set; } + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/AmtocBots.Api/Models/ModelSwitchRule.cs b/src/AmtocBots.Api/Models/ModelSwitchRule.cs new file mode 100644 index 0000000..0adc21c --- /dev/null +++ b/src/AmtocBots.Api/Models/ModelSwitchRule.cs @@ -0,0 +1,23 @@ +namespace AmtocBots.Api.Models; + +public sealed class ModelSwitchRule +{ + public Guid Id { get; set; } + public Guid InstanceId { get; set; } + public BotInstance? Instance { get; set; } + + /// threshold | cron | manual + public string RuleType { get; set; } = "threshold"; + + // Threshold rules + public string? TriggerModel { get; set; } + public int? ThresholdPct { get; set; } + + // Cron rules + public string? CronExpression { get; set; } + + public string TargetModel { get; set; } = string.Empty; + public bool IsActive { get; set; } = true; + public int Priority { get; set; } + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/AmtocBots.Api/Models/TokenUsageRecord.cs b/src/AmtocBots.Api/Models/TokenUsageRecord.cs new file mode 100644 index 0000000..328706e --- /dev/null +++ b/src/AmtocBots.Api/Models/TokenUsageRecord.cs @@ -0,0 +1,15 @@ +namespace AmtocBots.Api.Models; + +public sealed class TokenUsageRecord +{ + public Guid Id { get; set; } + public Guid InstanceId { get; set; } + public BotInstance? Instance { get; set; } + + public string Model { get; set; } = string.Empty; + public DateOnly UsageDate { get; set; } = DateOnly.FromDateTime(DateTime.UtcNow); + public long PromptTokens { get; set; } + public long CompletionTokens { get; set; } + public long TotalTokens { get; set; } + public decimal? EstimatedCostUsd { get; set; } +} diff --git a/src/AmtocBots.Api/Program.cs b/src/AmtocBots.Api/Program.cs new file mode 100644 index 0000000..c96d723 --- /dev/null +++ b/src/AmtocBots.Api/Program.cs @@ -0,0 +1,125 @@ +using AmtocBots.Api.BackgroundServices; +using AmtocBots.Api.Configuration; +using AmtocBots.Api.Data; +using AmtocBots.Api.Endpoints; +using AmtocBots.Api.Hubs; +using AmtocBots.Api.Services.Docker; +using AmtocBots.Api.Services.Models; +using AmtocBots.Api.Services.Ollama; +using AmtocBots.Api.Services.OpenClaw; +using AmtocBots.Api.Services.Queue; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Pgvector.EntityFrameworkCore; +using StackExchange.Redis; + +var builder = WebApplication.CreateBuilder(args); + +// ── Options ─────────────────────────────────────────────────────────────────── +builder.Services.Configure(builder.Configuration.GetSection("Docker")); +builder.Services.Configure(builder.Configuration.GetSection("Ollama")); +builder.Services.Configure(builder.Configuration.GetSection("Encryption")); + +// ── Database ────────────────────────────────────────────────────────────────── +builder.Services.AddDbContext(opt => + opt.UseNpgsql( + builder.Configuration.GetConnectionString("Default"), + o => o.UseVector())); + +// ── Redis ───────────────────────────────────────────────────────────────────── +var redisConn = builder.Configuration["Redis:ConnectionString"] + ?? throw new InvalidOperationException("Redis:ConnectionString not configured"); +builder.Services.AddSingleton(_ => ConnectionMultiplexer.Connect(redisConn)); +builder.Services.AddStackExchangeRedisCache(o => o.Configuration = redisConn); + +// ── Auth (Keycloak JWT) ─────────────────────────────────────────────────────── +var keycloakAuthority = builder.Configuration["Keycloak:Authority"] + ?? throw new InvalidOperationException("Keycloak:Authority not configured"); +var keycloakAudience = builder.Configuration["Keycloak:Audience"] ?? "amtocbots-api"; + +builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(opt => + { + opt.Authority = keycloakAuthority; + opt.Audience = keycloakAudience; + opt.RequireHttpsMetadata = !builder.Environment.IsDevelopment(); + opt.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + ValidateIssuer = true, + ValidateAudience = true, + NameClaimType = "preferred_username", + RoleClaimType = "realm_access.roles", + }; + // Allow JWT in SignalR query string + opt.Events = new JwtBearerEvents + { + OnMessageReceived = ctx => + { + var token = ctx.Request.Query["access_token"]; + if (!string.IsNullOrEmpty(token) && + ctx.HttpContext.Request.Path.StartsWithSegments("/hubs")) + ctx.Token = token; + return Task.CompletedTask; + }, + }; + }); + +builder.Services.AddAuthorizationBuilder() + .AddPolicy("AdminOnly", p => p.RequireRole("admin")) + .AddPolicy("Operator", p => p.RequireRole("admin", "operator")); + +// ── Services ────────────────────────────────────────────────────────────────── +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(sp => sp.GetRequiredService()); +builder.Services.AddHttpClient(c => + c.BaseAddress = new Uri(builder.Configuration["Ollama:BaseUrl"] ?? "http://localhost:11434")); + +// ── Background Services ─────────────────────────────────────────────────────── +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); + +// ── SignalR ─────────────────────────────────────────────────────────────────── +builder.Services.AddSignalR(); + +// ── Controllers + API Explorer ──────────────────────────────────────────────── +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); + +// ── CORS ────────────────────────────────────────────────────────────────────── +var corsOrigins = builder.Configuration["Cors:Origins"]?.Split(',') ?? ["http://localhost:4200"]; +builder.Services.AddCors(o => o.AddDefaultPolicy(p => + p.WithOrigins(corsOrigins).AllowAnyHeader().AllowAnyMethod().AllowCredentials())); + +var app = builder.Build(); + +// ── Migrate on startup ──────────────────────────────────────────────────────── +await using (var scope = app.Services.CreateAsyncScope()) +{ + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); +} + +// ── Middleware ──────────────────────────────────────────────────────────────── +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); + +// ── Routes ──────────────────────────────────────────────────────────────────── +app.MapControllers(); +app.MapHub("/hubs/instances"); +app.MapHub("/hubs/kanban"); +app.MapHub("/hubs/chat"); + +app.MapGroup("/api/health").MapHealthEndpoints(); +app.MapGroup("/api/channels").MapChannelEndpoints().RequireAuthorization(); +app.MapGroup("/api/ollama").MapOllamaEndpoints().RequireAuthorization(); + +app.Run(); diff --git a/src/AmtocBots.Api/Services/Docker/DockerService.cs b/src/AmtocBots.Api/Services/Docker/DockerService.cs new file mode 100644 index 0000000..2fa385d --- /dev/null +++ b/src/AmtocBots.Api/Services/Docker/DockerService.cs @@ -0,0 +1,167 @@ +using AmtocBots.Api.Configuration; +using AmtocBots.Api.Models; +using Docker.DotNet; +using Docker.DotNet.Models; +using Microsoft.Extensions.Options; + +namespace AmtocBots.Api.Services.Docker; + +public sealed class DockerService : IDockerService, IDisposable +{ + private readonly DockerClient _client; + private readonly DockerOptions _opts; + private readonly ILogger _log; + + public DockerService(IOptions opts, ILogger log) + { + _opts = opts.Value; + _log = log; + _client = new DockerClientConfiguration(new Uri($"unix://{_opts.SocketPath}")) + .CreateClient(); + } + + public async Task CreateAndStartContainerAsync(BotInstance instance, CancellationToken ct = default) + { + var containerName = instance.ContainerName; + var volumeName = $"openclaw-config-{instance.Id}"; + + // Ensure config volume exists + await _client.Volumes.CreateAsync(new VolumesCreateParameters { Name = volumeName }, ct); + + var hostConfig = new HostConfig + { + NetworkMode = _opts.OpenClawNetwork, + Binds = [$"{volumeName}:/root/.openclaw"], + RestartPolicy = new RestartPolicy { Name = RestartPolicyKind.UnlessStopped }, + }; + + if (instance.MemoryLimitMb.HasValue) + hostConfig.Memory = instance.MemoryLimitMb.Value * 1024L * 1024L; + + if (instance.CpuLimit.HasValue) + hostConfig.NanoCPUs = (long)(instance.CpuLimit.Value * 1e9); + + var create = await _client.Containers.CreateContainerAsync(new CreateContainerParameters + { + Name = containerName, + Image = _opts.OpenClawImage, + HostConfig = hostConfig, + Labels = new Dictionary + { + ["amtocbots.managed"] = "true", + ["amtocbots.instance-id"] = instance.Id.ToString(), + }, + }, ct); + + await _client.Containers.StartContainerAsync(create.ID, new ContainerStartParameters(), ct); + _log.LogInformation("Started container {Name} ({Id})", containerName, create.ID); + return create.ID; + } + + public async Task StopContainerAsync(string containerId, CancellationToken ct = default) + { + await _client.Containers.StopContainerAsync(containerId, + new ContainerStopParameters { WaitBeforeKillSeconds = 10 }, ct); + } + + public async Task RestartContainerAsync(string containerId, CancellationToken ct = default) + { + await _client.Containers.RestartContainerAsync(containerId, + new ContainerRestartParameters { WaitBeforeKillSeconds = 10 }, ct); + } + + public async Task RemoveContainerAsync(string containerId, bool force = true, CancellationToken ct = default) + { + await _client.Containers.RemoveContainerAsync(containerId, + new ContainerRemoveParameters { Force = force, RemoveVolumes = false }, ct); + } + + public async Task GetStatsAsync(Guid instanceId, string containerId, CancellationToken ct = default) + { + try + { + var inspect = await _client.Containers.InspectContainerAsync(containerId, ct); + var status = inspect.State.Running ? "running" : inspect.State.Status; + + // Stats stream — read one sample then cancel + double cpuPct = 0; + long memUsage = 0, memLimit = 0; + + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + await _client.Containers.GetContainerStatsAsync(containerId, + new ContainerStatsParameters { Stream = false }, + new Progress(s => + { + cpuPct = CalculateCpuPercent(s); + memUsage = (long)(s.MemoryStats.Usage ?? 0) / (1024 * 1024); + memLimit = (long)(s.MemoryStats.Limit ?? 0) / (1024 * 1024); + cts.Cancel(); + }), cts.Token); + + return new ContainerStats(instanceId, status, cpuPct, memUsage, memLimit); + } + catch (Exception ex) + { + _log.LogWarning(ex, "Could not get stats for container {Id}", containerId); + return null; + } + } + + public async Task> GetAllManagedStatsAsync( + IEnumerable<(Guid Id, string ContainerId)> instances, CancellationToken ct = default) + { + var tasks = instances.Select(i => GetStatsAsync(i.Id, i.ContainerId, ct)); + var results = await Task.WhenAll(tasks); + return results.OfType().ToList(); + } + + public async Task GetLogsAsync(string containerId, int tailLines = 200, CancellationToken ct = default) + { + var stream = await _client.Containers.GetContainerLogsAsync(containerId, + false, + new ContainerLogsParameters + { + ShowStdout = true, + ShowStderr = true, + Tail = tailLines.ToString(), + Timestamps = true, + }, ct); + + var (stdout, _) = await stream.ReadOutputToEndAsync(ct); + return stdout; + } + + public async Task WriteConfigVolumeAsync(Guid instanceId, string json5Content, CancellationToken ct = default) + { + // Write the JSON5 config into the named volume via a temporary busybox container + var volumeName = $"openclaw-config-{instanceId}"; + var encoded = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(json5Content)); + + var create = await _client.Containers.CreateContainerAsync(new CreateContainerParameters + { + Image = "busybox", + Cmd = ["sh", "-c", $"echo {encoded} | base64 -d > /data/openclaw.json"], + HostConfig = new HostConfig + { + Binds = [$"{volumeName}:/data"], + AutoRemove = true, + }, + }, ct); + + await _client.Containers.StartContainerAsync(create.ID, new ContainerStartParameters(), ct); + + // Wait for the helper container to finish + await _client.Containers.WaitContainerAsync(create.ID, ct); + _log.LogDebug("Config written to volume {Volume} for instance {Id}", volumeName, instanceId); + } + + private static double CalculateCpuPercent(ContainerStatsResponse s) + { + var cpuDelta = (double)(s.CPUStats.CPUUsage.TotalUsage - s.PreCPUStats.CPUUsage.TotalUsage); + var systemDelta = (double)(s.CPUStats.SystemUsage - s.PreCPUStats.SystemUsage); + var numCpus = s.CPUStats.OnlineCPUs > 0 ? s.CPUStats.OnlineCPUs : (ulong)Environment.ProcessorCount; + return systemDelta > 0 ? cpuDelta / systemDelta * numCpus * 100.0 : 0; + } + + public void Dispose() => _client.Dispose(); +} diff --git a/src/AmtocBots.Api/Services/Docker/IDockerService.cs b/src/AmtocBots.Api/Services/Docker/IDockerService.cs new file mode 100644 index 0000000..190505a --- /dev/null +++ b/src/AmtocBots.Api/Services/Docker/IDockerService.cs @@ -0,0 +1,22 @@ +using AmtocBots.Api.Models; + +namespace AmtocBots.Api.Services.Docker; + +public sealed record ContainerStats( + Guid InstanceId, + string Status, + double CpuPercent, + long MemoryUsageMb, + long MemoryLimitMb); + +public interface IDockerService +{ + Task CreateAndStartContainerAsync(BotInstance instance, CancellationToken ct = default); + Task StopContainerAsync(string containerId, CancellationToken ct = default); + Task RestartContainerAsync(string containerId, CancellationToken ct = default); + Task RemoveContainerAsync(string containerId, bool force = true, CancellationToken ct = default); + Task GetStatsAsync(Guid instanceId, string containerId, CancellationToken ct = default); + Task> GetAllManagedStatsAsync(IEnumerable<(Guid Id, string ContainerId)> instances, CancellationToken ct = default); + Task GetLogsAsync(string containerId, int tailLines = 200, CancellationToken ct = default); + Task WriteConfigVolumeAsync(Guid instanceId, string json5Content, CancellationToken ct = default); +} diff --git a/src/AmtocBots.Api/Services/Models/IModelSwitchingService.cs b/src/AmtocBots.Api/Services/Models/IModelSwitchingService.cs new file mode 100644 index 0000000..63720ff --- /dev/null +++ b/src/AmtocBots.Api/Services/Models/IModelSwitchingService.cs @@ -0,0 +1,7 @@ +namespace AmtocBots.Api.Services.Models; + +public interface IModelSwitchingService +{ + Task EvaluateThresholdRulesAsync(Guid instanceId, CancellationToken ct = default); + Task SwitchModelAsync(Guid instanceId, string targetModel, CancellationToken ct = default); +} diff --git a/src/AmtocBots.Api/Services/Models/ITokenTracker.cs b/src/AmtocBots.Api/Services/Models/ITokenTracker.cs new file mode 100644 index 0000000..02a75ae --- /dev/null +++ b/src/AmtocBots.Api/Services/Models/ITokenTracker.cs @@ -0,0 +1,15 @@ +namespace AmtocBots.Api.Services.Models; + +public sealed record TokenUsageSample( + Guid InstanceId, + string Model, + long PromptTokens, + long CompletionTokens, + decimal? CostUsd = null); + +public interface ITokenTracker +{ + Task RecordAsync(TokenUsageSample sample, CancellationToken ct = default); + Task<(long Prompt, long Completion, long Total)> GetTodayTotalsAsync(Guid instanceId, string model, CancellationToken ct = default); + Task> GetAllModelTotalsForInstanceAsync(Guid instanceId, DateOnly date, CancellationToken ct = default); +} diff --git a/src/AmtocBots.Api/Services/Models/ModelSwitchingService.cs b/src/AmtocBots.Api/Services/Models/ModelSwitchingService.cs new file mode 100644 index 0000000..f11a0fa --- /dev/null +++ b/src/AmtocBots.Api/Services/Models/ModelSwitchingService.cs @@ -0,0 +1,86 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Services.Docker; +using AmtocBots.Api.Services.OpenClaw; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Services.Models; + +public sealed class ModelSwitchingService( + AppDbContext db, + IDockerService docker, + OpenClawConfigBuilder configBuilder, + ILogger log) : IModelSwitchingService +{ + // Daily token limits per model provider (configurable, sensible defaults) + private static readonly Dictionary DailyTokenLimits = new() + { + ["anthropic/"] = 1_000_000, + ["openai/"] = 1_000_000, + ["google/"] = 1_000_000, + }; + + public async Task EvaluateThresholdRulesAsync(Guid instanceId, CancellationToken ct = default) + { + var rules = await db.SwitchRules + .Where(r => r.InstanceId == instanceId && r.IsActive && r.RuleType == "threshold") + .OrderByDescending(r => r.Priority) + .ToListAsync(ct); + + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + foreach (var rule in rules) + { + if (rule.TriggerModel is null || rule.ThresholdPct is null) continue; + + var usage = await db.TokenUsage + .Where(u => u.InstanceId == instanceId && u.Model == rule.TriggerModel && u.UsageDate == today) + .FirstOrDefaultAsync(ct); + + if (usage is null) continue; + + var limit = GetDailyLimit(rule.TriggerModel); + var usedPct = (int)(usage.TotalTokens * 100 / limit); + + if (usedPct >= rule.ThresholdPct) + { + log.LogInformation("Instance {Id}: model {Model} at {Pct}% — switching to {Target}", + instanceId, rule.TriggerModel, usedPct, rule.TargetModel); + await SwitchModelAsync(instanceId, rule.TargetModel, ct); + return; // highest-priority rule wins + } + } + } + + public async Task SwitchModelAsync(Guid instanceId, string targetModel, CancellationToken ct = default) + { + var instance = await db.Instances + .Include(i => i.ChannelConfigs) + .FirstOrDefaultAsync(i => i.Id == instanceId, ct) + ?? throw new InvalidOperationException($"Instance {instanceId} not found"); + + if (instance.CurrentModel == targetModel) return; + + instance.CurrentModel = targetModel; + instance.UpdatedAt = DateTimeOffset.UtcNow; + + // Rebuild and write config + var token = instance.ApiBearerTokenHash ?? string.Empty; // actual token stored separately + var config = configBuilder.Build(instance, instance.ChannelConfigs, token); + await docker.WriteConfigVolumeAsync(instanceId, config, ct); + + // Restart container to pick up new model + if (instance.ContainerId is not null) + await docker.RestartContainerAsync(instance.ContainerId, ct); + + await db.SaveChangesAsync(ct); + log.LogInformation("Instance {Id} switched to model {Model}", instanceId, targetModel); + } + + private static long GetDailyLimit(string model) + { + foreach (var (prefix, limit) in DailyTokenLimits) + if (model.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return limit; + return long.MaxValue; // Ollama / unknown → no limit + } +} diff --git a/src/AmtocBots.Api/Services/Models/TokenTracker.cs b/src/AmtocBots.Api/Services/Models/TokenTracker.cs new file mode 100644 index 0000000..3a3b902 --- /dev/null +++ b/src/AmtocBots.Api/Services/Models/TokenTracker.cs @@ -0,0 +1,59 @@ +using AmtocBots.Api.Data; +using AmtocBots.Api.Models; +using Microsoft.EntityFrameworkCore; + +namespace AmtocBots.Api.Services.Models; + +public sealed class TokenTracker(AppDbContext db) : ITokenTracker +{ + public async Task RecordAsync(TokenUsageSample sample, CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + + // Upsert — increment today's record + var existing = await db.TokenUsage + .FirstOrDefaultAsync(r => r.InstanceId == sample.InstanceId + && r.Model == sample.Model + && r.UsageDate == today, ct); + if (existing is null) + { + db.TokenUsage.Add(new TokenUsageRecord + { + InstanceId = sample.InstanceId, + Model = sample.Model, + UsageDate = today, + PromptTokens = sample.PromptTokens, + CompletionTokens = sample.CompletionTokens, + TotalTokens = sample.PromptTokens + sample.CompletionTokens, + EstimatedCostUsd = sample.CostUsd, + }); + } + else + { + existing.PromptTokens += sample.PromptTokens; + existing.CompletionTokens += sample.CompletionTokens; + existing.TotalTokens += sample.PromptTokens + sample.CompletionTokens; + if (sample.CostUsd.HasValue) + existing.EstimatedCostUsd = (existing.EstimatedCostUsd ?? 0) + sample.CostUsd.Value; + } + + await db.SaveChangesAsync(ct); + } + + public async Task<(long Prompt, long Completion, long Total)> GetTodayTotalsAsync( + Guid instanceId, string model, CancellationToken ct = default) + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var rec = await db.TokenUsage + .FirstOrDefaultAsync(r => r.InstanceId == instanceId && r.Model == model && r.UsageDate == today, ct); + return rec is null ? (0, 0, 0) : (rec.PromptTokens, rec.CompletionTokens, rec.TotalTokens); + } + + public async Task> GetAllModelTotalsForInstanceAsync( + Guid instanceId, DateOnly date, CancellationToken ct = default) + { + return await db.TokenUsage + .Where(r => r.InstanceId == instanceId && r.UsageDate == date) + .ToDictionaryAsync(r => r.Model, r => r.TotalTokens, ct); + } +} diff --git a/src/AmtocBots.Api/Services/Ollama/IOllamaService.cs b/src/AmtocBots.Api/Services/Ollama/IOllamaService.cs new file mode 100644 index 0000000..be4658f --- /dev/null +++ b/src/AmtocBots.Api/Services/Ollama/IOllamaService.cs @@ -0,0 +1,11 @@ +namespace AmtocBots.Api.Services.Ollama; + +public sealed record OllamaModel(string Name, string? Size, DateTimeOffset? ModifiedAt); + +public interface IOllamaService +{ + Task> ListModelsAsync(CancellationToken ct = default); + Task PullModelAsync(string modelName, CancellationToken ct = default); + Task IsHealthyAsync(CancellationToken ct = default); + Task GenerateEmbeddingAsync(string text, string model = "nomic-embed-text", CancellationToken ct = default); +} diff --git a/src/AmtocBots.Api/Services/Ollama/OllamaService.cs b/src/AmtocBots.Api/Services/Ollama/OllamaService.cs new file mode 100644 index 0000000..834e0c8 --- /dev/null +++ b/src/AmtocBots.Api/Services/Ollama/OllamaService.cs @@ -0,0 +1,58 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AmtocBots.Api.Services.Ollama; + +public sealed class OllamaService(HttpClient http, ILogger log) : IOllamaService +{ + public async Task> ListModelsAsync(CancellationToken ct = default) + { + try + { + var resp = await http.GetFromJsonAsync("/api/tags", ct); + return resp?.Models.Select(m => new OllamaModel(m.Name, m.Size, m.ModifiedAt)).ToList() + ?? []; + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to list Ollama models"); + return []; + } + } + + public async Task PullModelAsync(string modelName, CancellationToken ct = default) + { + var body = new StringContent(JsonSerializer.Serialize(new { name = modelName }), Encoding.UTF8, "application/json"); + using var resp = await http.PostAsync("/api/pull", body, ct); + resp.EnsureSuccessStatusCode(); + } + + public async Task IsHealthyAsync(CancellationToken ct = default) + { + try + { + var resp = await http.GetAsync("/", ct); + return resp.IsSuccessStatusCode; + } + catch { return false; } + } + + public async Task GenerateEmbeddingAsync(string text, string model = "nomic-embed-text", CancellationToken ct = default) + { + var payload = new StringContent( + JsonSerializer.Serialize(new { model, prompt = text }), + Encoding.UTF8, "application/json"); + var resp = await http.PostAsync("/api/embeddings", payload, ct); + resp.EnsureSuccessStatusCode(); + var result = await resp.Content.ReadFromJsonAsync(cancellationToken: ct); + return result?.Embedding ?? []; + } + + private sealed record OllamaListResponse([property: JsonPropertyName("models")] List Models); + private sealed record OllamaModelDto( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("size")] string? Size, + [property: JsonPropertyName("modified_at")] DateTimeOffset? ModifiedAt); + private sealed record OllamaEmbeddingResponse([property: JsonPropertyName("embedding")] float[] Embedding); +} diff --git a/src/AmtocBots.Api/Services/OpenClaw/IOpenClawClient.cs b/src/AmtocBots.Api/Services/OpenClaw/IOpenClawClient.cs new file mode 100644 index 0000000..c6b6132 --- /dev/null +++ b/src/AmtocBots.Api/Services/OpenClaw/IOpenClawClient.cs @@ -0,0 +1,16 @@ +namespace AmtocBots.Api.Services.OpenClaw; + +public sealed record AgentRequest( + string Description, + string? Model = null, + string? Channel = null); + +public sealed record QrCodeResponse(byte[] ImageBytes, string ContentType); + +public interface IOpenClawClient +{ + Task WakeAsync(string baseUrl, string bearerToken, string description, CancellationToken ct = default); + Task RunAgentAsync(string baseUrl, string bearerToken, AgentRequest request, CancellationToken ct = default); + Task GetWhatsAppQrAsync(string baseUrl, string bearerToken, CancellationToken ct = default); + Task IsHealthyAsync(string baseUrl, CancellationToken ct = default); +} diff --git a/src/AmtocBots.Api/Services/OpenClaw/OpenClawClient.cs b/src/AmtocBots.Api/Services/OpenClaw/OpenClawClient.cs new file mode 100644 index 0000000..2b3456e --- /dev/null +++ b/src/AmtocBots.Api/Services/OpenClaw/OpenClawClient.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace AmtocBots.Api.Services.OpenClaw; + +public sealed class OpenClawClient(HttpClient http, ILogger log) : IOpenClawClient +{ + public async Task WakeAsync(string baseUrl, string bearerToken, string description, CancellationToken ct = default) + { + using var req = BuildRequest(HttpMethod.Post, baseUrl, "/hooks/wake", bearerToken); + req.Content = JsonContent(new { description, trigger = "now" }); + var resp = await http.SendAsync(req, ct); + resp.EnsureSuccessStatusCode(); + } + + public async Task RunAgentAsync(string baseUrl, string bearerToken, AgentRequest request, CancellationToken ct = default) + { + using var req = BuildRequest(HttpMethod.Post, baseUrl, "/hooks/agent", bearerToken); + req.Content = JsonContent(new + { + description = request.Description, + model = request.Model, + channel = request.Channel, + }); + var resp = await http.SendAsync(req, ct); + resp.EnsureSuccessStatusCode(); + } + + public async Task GetWhatsAppQrAsync(string baseUrl, string bearerToken, CancellationToken ct = default) + { + try + { + using var req = BuildRequest(HttpMethod.Get, baseUrl, "/whatsapp/qr", bearerToken); + var resp = await http.SendAsync(req, ct); + if (!resp.IsSuccessStatusCode) return null; + var bytes = await resp.Content.ReadAsByteArrayAsync(ct); + var ct2 = resp.Content.Headers.ContentType?.MediaType ?? "image/png"; + return new QrCodeResponse(bytes, ct2); + } + catch (Exception ex) + { + log.LogWarning(ex, "Failed to fetch WhatsApp QR from {Url}", baseUrl); + return null; + } + } + + public async Task IsHealthyAsync(string baseUrl, CancellationToken ct = default) + { + try + { + var resp = await http.GetAsync($"{baseUrl.TrimEnd('/')}/health", ct); + return resp.IsSuccessStatusCode; + } + catch + { + return false; + } + } + + private static HttpRequestMessage BuildRequest(HttpMethod method, string baseUrl, string path, string token) + { + var req = new HttpRequestMessage(method, $"{baseUrl.TrimEnd('/')}{path}"); + req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + return req; + } + + private static StringContent JsonContent(object payload) => + new(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); +} diff --git a/src/AmtocBots.Api/Services/OpenClaw/OpenClawConfigBuilder.cs b/src/AmtocBots.Api/Services/OpenClaw/OpenClawConfigBuilder.cs new file mode 100644 index 0000000..93bf544 --- /dev/null +++ b/src/AmtocBots.Api/Services/OpenClaw/OpenClawConfigBuilder.cs @@ -0,0 +1,104 @@ +using AmtocBots.Api.Models; +using System.Text; +using System.Text.Json; + +namespace AmtocBots.Api.Services.OpenClaw; + +/// +/// Builds an OpenClaw JSON5 configuration from a BotInstance + its ChannelConfigs. +/// The resulting string is written to the Docker volume via DockerService.WriteConfigVolumeAsync. +/// +public sealed class OpenClawConfigBuilder +{ + public string Build(BotInstance instance, IEnumerable channels, string bearerToken) + { + var sb = new StringBuilder(); + sb.AppendLine("// Auto-generated by AmtocBots Manager. Edit via the UI."); + sb.AppendLine("{"); + sb.AppendLine($" identity: {{ name: {Json(instance.Name)}, theme: \"helpful assistant\", emoji: \"🤖\" }},"); + sb.AppendLine(" agent: {"); + sb.AppendLine(" workspace: \"/root/.openclaw/workspace\","); + sb.AppendLine(" model: {"); + sb.AppendLine($" primary: {Json(instance.CurrentModel)},"); + sb.AppendLine(" },"); + sb.AppendLine(" },"); + sb.AppendLine(" channels: {"); + + foreach (var ch in channels.Where(c => c.IsEnabled)) + { + var cfg = JsonDocument.Parse(ch.ConfigJson).RootElement; + switch (ch.ChannelType) + { + case "telegram": + AppendTelegram(sb, cfg); + break; + case "whatsapp": + AppendWhatsApp(sb, cfg); + break; + case "discord": + AppendDiscord(sb, cfg); + break; + case "slack": + AppendSlack(sb, cfg); + break; + } + } + + sb.AppendLine(" },"); + sb.AppendLine(" gateway: {"); + sb.AppendLine(" port: 18789,"); + sb.AppendLine(" auth: {"); + sb.AppendLine($" tokens: [{Json(bearerToken)}],"); + sb.AppendLine(" },"); + sb.AppendLine(" },"); + sb.AppendLine("}"); + return sb.ToString(); + } + + private static void AppendTelegram(StringBuilder sb, JsonElement cfg) + { + sb.AppendLine(" telegram: {"); + sb.AppendLine($" token: {Json(cfg.TryGet("token"))},"); + if (cfg.TryGetProperty("allowFrom", out var af)) + sb.AppendLine($" allowFrom: {af},"); + sb.AppendLine(" },"); + } + + private static void AppendWhatsApp(StringBuilder sb, JsonElement cfg) + { + sb.AppendLine(" whatsapp: {"); + if (cfg.TryGetProperty("allowFrom", out var af)) + sb.AppendLine($" allowFrom: {af},"); + sb.AppendLine(" },"); + } + + private static void AppendDiscord(StringBuilder sb, JsonElement cfg) + { + sb.AppendLine(" discord: {"); + sb.AppendLine($" token: {Json(cfg.TryGet("token"))},"); + if (cfg.TryGetProperty("guildId", out var g)) + sb.AppendLine($" guildId: {Json(g.GetString())},"); + if (cfg.TryGetProperty("allowChannels", out var ac)) + sb.AppendLine($" allowChannels: {ac},"); + sb.AppendLine(" },"); + } + + private static void AppendSlack(StringBuilder sb, JsonElement cfg) + { + sb.AppendLine(" slack: {"); + sb.AppendLine($" appToken: {Json(cfg.TryGet("appToken"))},"); + sb.AppendLine($" botToken: {Json(cfg.TryGet("botToken"))},"); + if (cfg.TryGetProperty("allowChannels", out var ac)) + sb.AppendLine($" allowChannels: {ac},"); + sb.AppendLine(" },"); + } + + private static string Json(string? value) => + value is null ? "null" : JsonSerializer.Serialize(value); +} + +file static class JsonElementExtensions +{ + public static string? TryGet(this JsonElement el, string prop) => + el.TryGetProperty(prop, out var v) ? v.GetString() : null; +} diff --git a/src/AmtocBots.Api/Services/Queue/IMessageQueueService.cs b/src/AmtocBots.Api/Services/Queue/IMessageQueueService.cs new file mode 100644 index 0000000..41fdef2 --- /dev/null +++ b/src/AmtocBots.Api/Services/Queue/IMessageQueueService.cs @@ -0,0 +1,16 @@ +namespace AmtocBots.Api.Services.Queue; + +public sealed record QueuedAgentMessage( + Guid InstanceId, + string BaseUrl, + string BearerToken, + string Description, + string? Model, + int RetryCount = 0); + +public interface IMessageQueueService +{ + Task EnqueueAsync(QueuedAgentMessage message, CancellationToken ct = default); + Task DequeueAsync(CancellationToken ct = default); + Task RequeueWithDelayAsync(QueuedAgentMessage message, TimeSpan delay, CancellationToken ct = default); +} diff --git a/src/AmtocBots.Api/Services/Queue/RedisMessageQueueService.cs b/src/AmtocBots.Api/Services/Queue/RedisMessageQueueService.cs new file mode 100644 index 0000000..2b7a894 --- /dev/null +++ b/src/AmtocBots.Api/Services/Queue/RedisMessageQueueService.cs @@ -0,0 +1,46 @@ +using StackExchange.Redis; +using System.Text.Json; + +namespace AmtocBots.Api.Services.Queue; + +public sealed class RedisMessageQueueService(IConnectionMultiplexer redis) : IMessageQueueService +{ + private const string QueueKey = "queue:agent-messages"; + + public async Task EnqueueAsync(QueuedAgentMessage message, CancellationToken ct = default) + { + var db = redis.GetDatabase(); + var json = JsonSerializer.Serialize(message); + await db.ListLeftPushAsync(QueueKey, json); + } + + public async Task DequeueAsync(CancellationToken ct = default) + { + var db = redis.GetDatabase(); + var value = await db.ListRightPopAsync(QueueKey); + if (value.IsNullOrEmpty) return null; + return JsonSerializer.Deserialize(value!); + } + + public async Task RequeueWithDelayAsync(QueuedAgentMessage message, TimeSpan delay, CancellationToken ct = default) + { + // Use a sorted set as a delay queue (score = scheduled timestamp) + var db = redis.GetDatabase(); + var json = JsonSerializer.Serialize(message with { RetryCount = message.RetryCount + 1 }); + var score = DateTimeOffset.UtcNow.Add(delay).ToUnixTimeSeconds(); + await db.SortedSetAddAsync("queue:agent-delayed", json, score); + } + + /// Move ready delayed messages back to the main queue. Called by QueueRetryWorker. + public async Task FlushReadyDelayedAsync(CancellationToken ct = default) + { + var db = redis.GetDatabase(); + var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + var ready = await db.SortedSetRangeByScoreAsync("queue:agent-delayed", 0, now); + foreach (var item in ready) + { + await db.SortedSetRemoveAsync("queue:agent-delayed", item); + await db.ListLeftPushAsync(QueueKey, item); + } + } +} diff --git a/src/AmtocBots.Api/appsettings.Development.json b/src/AmtocBots.Api/appsettings.Development.json new file mode 100644 index 0000000..b05b289 --- /dev/null +++ b/src/AmtocBots.Api/appsettings.Development.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Debug", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "ConnectionStrings": { + "Default": "Host=localhost;Port=5432;Database=amtocbots;Username=amtocbots;Password=devpassword" + }, + "Redis": { + "ConnectionString": "localhost:6379,password=devpassword" + }, + "Keycloak": { + "Authority": "http://localhost:8180/realms/amtocbots" + }, + "Ollama": { + "BaseUrl": "http://localhost:11434" + }, + "Encryption": { + "Key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + }, + "Cors": { + "Origins": "http://localhost:4200" + } +} diff --git a/src/AmtocBots.Api/appsettings.json b/src/AmtocBots.Api/appsettings.json new file mode 100644 index 0000000..1bbafec --- /dev/null +++ b/src/AmtocBots.Api/appsettings.json @@ -0,0 +1,36 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Default": "" + }, + "Redis": { + "ConnectionString": "" + }, + "Keycloak": { + "Authority": "", + "Audience": "amtocbots-api" + }, + "Docker": { + "SocketPath": "/var/run/docker.sock", + "OpenClawNetwork": "amtocbots_openclaw", + "OpenClawImage": "ghcr.io/openclaw/openclaw:latest", + "PortRangeStart": 18789, + "PortRangeEnd": 19789 + }, + "Ollama": { + "BaseUrl": "http://localhost:11434" + }, + "Encryption": { + "Key": "" + }, + "Cors": { + "Origins": "https://manager.amtocbot.com" + } +}