Модуль: запись и транскрипция (монитор звонков, Jibri HTTP API, лимит MAX_CONCURRENT_CALLS, Whisper, Supabase Storage, страница в вики, повторы), тесты

This commit is contained in:
crm-calls agent
2026-10-15 06:28:29 +00:00
parent 5319c965c8
commit b6c11c8ea0
17 changed files with 1438 additions and 8 deletions

View File

@@ -20,7 +20,7 @@ RUN dotnet publish backend/Calls.Api/Calls.Api.csproj -c Release -o /out --no-re
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& apt-get install -y --no-install-recommends curl tzdata \
&& rm -rf /var/lib/apt/lists/*
COPY --from=api /out ./
COPY --from=web /src/dist ./wwwroot

View File

@@ -67,6 +67,7 @@ public sealed class ApiTests : IClassFixture<ApiTests.App>
b.UseSetting("Supabase:Url", "https://supabase.test");
b.UseSetting("Supabase:PublishableKey", "sb_publishable_test");
b.UseSetting("Crm:Origins", "https://crm.test");
b.UseSetting("CALLS_BACKGROUND", "false");
b.ConfigureTestServices(s => s.AddHttpClient("supabase").ConfigurePrimaryHttpMessageHandler(() => new FakeSupabase()));
}
}

View File

@@ -0,0 +1,403 @@
using System.Net;
using System.Text;
using System.Text.Json;
using Calls.Api.Recording;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Time.Testing;
namespace Calls.Api.Tests;
/// <summary>Поддельный HTTP: обработчик получает запрос и тело, отвечает чем скажут.</summary>
public sealed class FakeHttp(Func<HttpRequestMessage, string, HttpResponseMessage> handle) : HttpMessageHandler, IHttpClientFactory
{
public List<(string Method, string Url, string Body)> Log { get; } = [];
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage req, CancellationToken ct)
{
var body = req.Content == null ? "" : await req.Content.ReadAsStringAsync(ct);
lock (Log) Log.Add((req.Method.Method, req.RequestUri!.ToString(), body));
return handle(req, body);
}
public HttpClient CreateClient(string name) => new(this, disposeHandler: false);
public static HttpResponseMessage Json(object o, HttpStatusCode code = HttpStatusCode.OK) =>
new(code) { Content = new StringContent(o as string ?? JsonSerializer.Serialize(o), Encoding.UTF8, "application/json") };
}
public class CallGateTests
{
private static (CallGate, FakeTimeProvider) Make(int max = 1)
{
var t = new FakeTimeProvider(DateTimeOffset.Parse("2026-10-15T10:00:00Z"));
return (new CallGate(new RecordingOptions { MaxConcurrentCalls = max }, t), t);
}
[Fact]
public void Limit1_OtherRoomDenied_SameRoomAllowed()
{
var (g, _) = Make();
g.Update(new() { ["ws-a"] = 2 });
Assert.True(g.TryEnter("ws-a")); // в идущий звонок — можно
Assert.False(g.TryEnter("ws-b")); // второй звонок — нет
Assert.False(g.CanEnter("ws-b"));
}
[Fact]
public void FreeServer_FirstRoomReservedForTwoMinutes()
{
var (g, t) = Make();
g.Update(new());
Assert.True(g.TryEnter("ws-a")); // пропуск выдан, человек ещё подключается
Assert.False(g.TryEnter("ws-b")); // бронь ws-a держит место
t.Advance(TimeSpan.FromMinutes(3));
g.Update(new()); // в ws-a так никто и не зашёл
Assert.True(g.TryEnter("ws-b"));
}
[Fact]
public void CallEnded_NextRoomAllowed()
{
var (g, t) = Make();
g.Update(new() { ["ws-a"] = 2 });
Assert.False(g.TryEnter("ws-b"));
g.Update(new());
Assert.True(g.TryEnter("ws-b"));
}
[Fact]
public void Limit2_TwoCallsAllowed()
{
var (g, _) = Make(2);
g.Update(new() { ["ws-a"] = 2 });
Assert.True(g.TryEnter("ws-b"));
Assert.False(g.TryEnter("ws-c"));
}
[Fact]
public void NoFreshProsodyData_DoesNotBlock()
{
var (g, t) = Make();
g.Update(new() { ["ws-a"] = 2 });
t.Advance(TimeSpan.FromMinutes(1));
Assert.True(g.TryEnter("ws-b"));
}
}
public class CallPageTests
{
private static readonly TimeZoneInfo Tz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Warsaw");
[Fact]
public void Title_InWarsawTime() =>
Assert.Equal("Дзвінок 2026-10-15 14:05", CallPage.Title(DateTimeOffset.Parse("2026-10-15T12:05:30Z"), Tz));
[Fact]
public void Markdown_HasDurationParticipantsGuestAudioAndTimings()
{
var md = CallPage.Markdown(DateTimeOffset.Parse("2026-10-15T12:05:00Z"), 3725,
[new("Анна", false), new("Іван", true)], "https://s.test/a.mp3",
new Transcript("uk", [new(3.4, 5, "Добрий день"), new(3661.9, 3663, "До побачення")]), Tz);
Assert.Contains("15.10.2026 14:05", md);
Assert.Contains("1 год 2 хв 5 с", md);
Assert.Contains("Анна, Іван (гість)", md);
Assert.Contains("[запис дзвінка](https://s.test/a.mp3)", md);
Assert.Contains("[00:00:03] Добрий день", md);
Assert.Contains("[01:01:01] До побачення", md);
}
}
/// <summary>Обработка записи от каталога Jibri до страницы в вики — на поддельных OpenAI, Supabase и вики.</summary>
public sealed class ProcessorTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), "calls-rec-" + Guid.NewGuid().ToString("N"));
private readonly FakeTimeProvider _time = new(DateTimeOffset.Parse("2026-10-15T12:00:00Z"));
private readonly Db _db;
private readonly CallsRepo _calls;
public int WikiStatus = 200; // что отвечает вики на создание страницы
public int StorageStatus = 200;
public readonly List<string> Pages = [];
public readonly List<string> Uploads = [];
public readonly List<string> Folders = [];
public ProcessorTests()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:DataDir"] = Path.Combine(_root, "db") }).Build();
_db = new Db(cfg);
_db.Init();
_calls = new CallsRepo(_db, _time);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_root, true); } catch { /* временная папка */ }
}
private RecordingOptions Opt(bool keys = true) => new()
{
RecordingsDir = Path.Combine(_root, "rec"), MinCallSeconds = 60,
OpenAiKey = keys ? "sk-test" : "", SupabaseUrl = "https://sb.test", SupabaseKey = "pk",
CrmUserEmail = keys ? "bot@test" : "", CrmUserPassword = keys ? "pw" : "", Bucket = "files", Prefix = "calls/",
WikiUrl = "https://wiki.test", WikiFolder = "Дзвінки",
};
private FakeHttp Http() => new((req, body) =>
{
var u = req.RequestUri!;
if (u.Host == "api.openai.com" || u.AbsolutePath.EndsWith("/audio/transcriptions"))
{
// в части 0 — сегменты 1–2 с и 50–52 с, в части 1 — 0–3 с (после сдвига должно стать 60–63 с)
var part = body.Contains("part-001") ? 1 : 0;
object[] segs = part == 0
? [new { start = 1.0, end = 2.0, text = " Привіт" }, new { start = 50.0, end = 52.0, text = "Кінець першої" }]
: [new { start = 0.0, end = 3.0, text = "Друга частина" }];
return FakeHttp.Json(new { language = "ukrainian", segments = segs });
}
if (u.AbsolutePath == "/auth/v1/token") return FakeHttp.Json(new { access_token = "bot-token", expires_in = 3600 });
if (u.AbsolutePath.StartsWith("/storage/v1/object/files/"))
{
if (StorageStatus != 200) return FakeHttp.Json(new { error = "boom" }, (HttpStatusCode)StorageStatus);
Uploads.Add(Uri.UnescapeDataString(u.AbsolutePath["/storage/v1/object/files/".Length..]));
return FakeHttp.Json(new { Key = "x" });
}
if (u.Host == "wiki.test")
{
Assert.Equal("bot-token", req.Headers.Authorization?.Parameter);
if (u.AbsolutePath == "/api/tree") return FakeHttp.Json(new
{
nodes = Folders.Select(f => new { id = "f1", parentId = (string?)null, kind = "folder", title = f }).ToArray(),
});
if (u.AbsolutePath == "/api/folders") { Folders.Add("Дзвінки"); return FakeHttp.Json(new { id = "f1" }, HttpStatusCode.Created); }
if (u.AbsolutePath == "/api/pages")
{
if (WikiStatus != 200) return FakeHttp.Json(new { error = "нема" }, (HttpStatusCode)WikiStatus);
Pages.Add(body);
return FakeHttp.Json(new { id = "p1" }, HttpStatusCode.Created);
}
}
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
private RecordingProcessor Proc(FakeHttp http, RecordingOptions o)
{
var crm = new CrmSession(http, o);
return new RecordingProcessor(_calls, o, new Transcriber(http, o), new AudioStorage(http, o, crm), new WikiPublisher(http, o, crm),
NullLogger<RecordingProcessor>.Instance, _time);
}
/// <summary>Звонок с готовой записью (как после finalize.sh): две части по 60 и 30 с.</summary>
private (CallRow Call, string Dir) EndedCall(double duration = 90)
{
var c = _calls.Create("ws-room", "wsteam0000001");
_calls.RecordingStarted(c.Id, c.Id);
_calls.Seen(c.Id, [new("Анна", false), new("Іван", true)]);
_calls.Ended(c.Id, _calls.Now, hadRecording: true);
var dir = Path.Combine(_root, "rec", c.Id);
Directory.CreateDirectory(Path.Combine(dir, "parts"));
File.WriteAllBytes(Path.Combine(dir, "audio.mp3"), new byte[1000]);
File.WriteAllText(Path.Combine(dir, "parts", "part-000.mp3"), "part-000");
File.WriteAllText(Path.Combine(dir, "parts", "part-001.mp3"), "part-001");
File.WriteAllText(Path.Combine(dir, "meta.json"), JsonSerializer.Serialize(new
{
audio = "audio.mp3", mime = "audio/mpeg", duration, bytes = 1000,
parts = new[] { new { file = "parts/part-000.mp3", duration = 60.0, bytes = 8 }, new { file = "parts/part-001.mp3", duration = duration - 60, bytes = 8 } },
}));
File.WriteAllText(Path.Combine(dir, "ready"), "");
return (_calls.Get(c.Id)!, dir);
}
[Fact]
public async Task Success_TranscriptOffsets_UploadPage_FilesDeleted()
{
var (call, dir) = EndedCall();
var http = Http();
await Proc(http, Opt()).RunOnceAsync(default);
var row = _calls.Get(call.Id)!;
Assert.Equal("done", row.Status);
Assert.False(Directory.Exists(dir)); // проверка 3: файлов не осталось
var up = Assert.Single(Uploads);
Assert.Matches(@"^calls/2026/10/[0-9a-f]{24}/dzvinok-2026-10-15-1400\.mp3$", up);
Assert.StartsWith("https://sb.test/storage/v1/object/public/files/calls/2026/10/", row.AudioUrl);
Assert.Equal(["Дзвінки"], Folders); // папку создали
var page = JsonDocument.Parse(Assert.Single(Pages)).RootElement;
Assert.Equal("Дзвінок 2026-10-15 14:00", page.GetProperty("title").GetString());
Assert.Equal("f1", page.GetProperty("parentId").GetString());
var md = page.GetProperty("markdown").GetString()!;
Assert.Contains("[00:00:01] Привіт", md);
Assert.Contains("[00:00:50] Кінець першої", md);
Assert.Contains("[00:01:00] Друга частина", md); // проверка 4: тайминги второй части сдвинуты на 60 с
Assert.Contains("Іван (гість)", md);
Assert.Contains(row.AudioUrl!, md);
}
[Fact]
public async Task ShortCall_Skipped_NoPage_FilesDeleted()
{
var (call, dir) = EndedCall(duration: 45);
var http = Http();
await Proc(http, Opt()).RunOnceAsync(default);
Assert.Equal("skipped", _calls.Get(call.Id)!.Status);
Assert.False(Directory.Exists(dir));
Assert.Empty(Pages);
Assert.Empty(http.Log); // наружу ничего не уходило
}
[Fact]
public async Task NoKeys_Waits_NothingSent_FilesKept()
{
var (call, dir) = EndedCall();
var http = Http();
await Proc(http, Opt(keys: false)).RunOnceAsync(default);
var row = _calls.Get(call.Id)!;
Assert.Equal("waiting", row.Status);
Assert.Contains("OPENAI_API_KEY", row.Error);
Assert.True(File.Exists(Path.Combine(dir, "audio.mp3")));
Assert.Empty(http.Log);
}
[Fact]
public async Task WikiError_FilesKept_RetryLater_ThenCompletes()
{
var (call, dir) = EndedCall();
var http = Http();
WikiStatus = 500;
var p = Proc(http, Opt());
await p.RunOnceAsync(default);
var row = _calls.Get(call.Id)!;
Assert.Equal("error", row.Status);
Assert.Contains("500", row.Error);
Assert.True(File.Exists(Path.Combine(dir, "audio.mp3"))); // проверка 6: файлы на месте
Assert.True(File.Exists(Path.Combine(dir, "transcript.json"))); // транскрипция сохранена
Assert.NotNull(row.AudioUrl); // аудио уже загружено
var transcribeCalls = http.Log.Count(l => l.Url.EndsWith("/audio/transcriptions"));
await p.RunOnceAsync(default); // пауза ещё не прошла — ничего не делаем
Assert.Equal("error", _calls.Get(call.Id)!.Status);
WikiStatus = 200; // «исправили»
_time.Advance(TimeSpan.FromMinutes(2));
await p.RunOnceAsync(default);
Assert.Equal("done", _calls.Get(call.Id)!.Status);
Assert.False(Directory.Exists(dir));
Assert.Single(Uploads); // повтор не грузил аудио второй раз
Assert.Equal(transcribeCalls, http.Log.Count(l => l.Url.EndsWith("/audio/transcriptions"))); // и не расшифровывал заново
}
[Fact]
public async Task UploadError_FilesKept()
{
var (call, dir) = EndedCall();
StorageStatus = 500;
await Proc(Http(), Opt()).RunOnceAsync(default);
Assert.Equal("error", _calls.Get(call.Id)!.Status);
Assert.True(Directory.Exists(dir));
Assert.Empty(Pages);
}
[Fact]
public async Task NoWorkspaceAccess_ClearError_FilesKept()
{
var (call, dir) = EndedCall();
WikiStatus = 403;
await Proc(Http(), Opt()).RunOnceAsync(default);
var row = _calls.Get(call.Id)!;
Assert.Equal("error", row.Status);
Assert.Contains("не має доступу до воркспейсу wsteam0000001", row.Error);
Assert.True(Directory.Exists(dir));
}
[Fact]
public async Task NotReadyYet_Waits_ThenTimesOutWithError()
{
var c = _calls.Create("ws-room", "wsteam0000001");
_calls.RecordingStarted(c.Id, c.Id);
_calls.Ended(c.Id, _calls.Now, true);
var p = Proc(Http(), Opt());
await p.RunOnceAsync(default);
Assert.Equal("ended", _calls.Get(c.Id)!.Status);
_time.Advance(TimeSpan.FromMinutes(31));
await p.RunOnceAsync(default);
Assert.Equal("error", _calls.Get(c.Id)!.Status);
}
[Fact]
public async Task PartTooBig_Error()
{
var (call, _) = EndedCall();
var o = Opt();
var big = new RecordingOptions
{
RecordingsDir = o.RecordingsDir, MinCallSeconds = 60, OpenAiKey = "k", SupabaseUrl = o.SupabaseUrl, SupabaseKey = "pk",
CrmUserEmail = "b", CrmUserPassword = "p", WikiUrl = o.WikiUrl, MaxPartBytes = 4,
};
await Proc(Http(), big).RunOnceAsync(default);
Assert.Contains("більша за ліміт", _calls.Get(call.Id)!.Error);
}
}
/// <summary>Монитор: начало звонка → Jibri startService, пустая комната → stopService и обработка.</summary>
public sealed class MonitorTests : IDisposable
{
private readonly string _dir = Path.Combine(Path.GetTempPath(), "calls-mon-" + Guid.NewGuid().ToString("N"));
public List<string> Occupants = [];
public string JibriState = "IDLE";
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_dir, true); } catch { /* временная папка */ }
}
[Fact]
public async Task TwoPeople_StartRecording_Empty_StopAndEnd()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Storage:DataDir"] = _dir, ["Jitsi:AppSecret"] = StoreTests.Secret,
["Calls:LinkSecret"] = "test-link-secret-0123456789abcdef0123456789",
}).Build();
var db = new Db(cfg); db.Init();
var opt = new RecordingOptions { EmptyGrace = TimeSpan.Zero, RecorderPassword = "rp" };
var gate = new CallGate(opt);
var store = new Store(db, new JitsiToken(cfg), cfg, gate: gate);
var calls = new CallsRepo(db);
var http = new FakeHttp((req, body) =>
{
var p = req.RequestUri!.AbsolutePath;
if (p == "/room") return FakeHttp.Json(Occupants.Select(n => new { jid = "x", email = "", display_name = n }).Append(new { jid = "rec", email = "", display_name = "" }).ToArray());
if (p.EndsWith("/health")) return FakeHttp.Json(new { status = new { busyStatus = JibriState } });
if (p.EndsWith("/startService")) { JibriState = "BUSY"; return FakeHttp.Json("{}"); }
if (p.EndsWith("/stopService")) { JibriState = "IDLE"; return FakeHttp.Json("{}"); }
return new HttpResponseMessage(HttpStatusCode.NotFound);
});
var mon = new CallMonitor(new ProsodyRooms(http, opt), new JibriClient(http, opt), gate, calls, store, opt, NullLogger<CallMonitor>.Instance);
// гость получил пропуск — комната попала под наблюдение
var link = store.GetGuestLink("wsteam0000001", new CrmUser("u1", "a@t", "Анна", 0));
var gs = store.JoinAsGuest(link.Token, "Іван")!;
var room = store.IssuePass(gs.Token)!.Room;
Occupants = ["Іван"];
await mon.TickAsync(default);
Assert.Empty(calls.AllOpen()); // один человек — ещё не звонок
Assert.False(gate.CanEnter("ws-other")); // но комната занята для лимита
Occupants = ["Іван", "Анна"];
await mon.TickAsync(default);
var call = Assert.Single(calls.AllOpen());
Assert.Equal("recording", call.Status);
var start = Assert.Single(http.Log, l => l.Url.EndsWith("/startService"));
var sp = JsonDocument.Parse(start.Body).RootElement;
Assert.Equal(room, sp.GetProperty("callParams").GetProperty("callUrlInfo").GetProperty("callName").GetString());
Assert.Equal("file", sp.GetProperty("sinkType").GetString());
Assert.Equal("recorder", sp.GetProperty("callLoginParams").GetProperty("username").GetString());
Occupants = [];
await mon.TickAsync(default);
Assert.Contains(http.Log, l => l.Url.EndsWith("/stopService"));
var ended = calls.Get(call.Id)!;
Assert.Equal("ended", ended.Status);
Assert.Contains(ended.Participants, p => p.Name == "Іван" && p.Guest);
Assert.Contains(ended.Participants, p => p.Name == "Анна" && !p.Guest);
Assert.True(gate.CanEnter("ws-other"));
}
}

View File

@@ -86,6 +86,40 @@ public sealed class Db
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS call_sessions_exp ON call_sessions(expires_at);
-- Выданные пропуски: какие комнаты отслеживать и кто гость (имя на странице звонка).
CREATE TABLE IF NOT EXISTS pass_log(
room TEXT NOT NULL,
workspace_id TEXT NOT NULL,
name TEXT NOT NULL,
kind TEXT NOT NULL,
issued_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS pass_log_room ON pass_log(room, issued_at);
CREATE INDEX IF NOT EXISTS pass_log_time ON pass_log(issued_at);
-- Звонки и их запись. session_id = каталог записи Jibri (/recordings/<session_id>).
-- status: active (идёт, без записи) → recording → ended → processing/waiting/error → done | skipped.
CREATE TABLE IF NOT EXISTS calls(
id TEXT PRIMARY KEY,
room TEXT NOT NULL,
workspace_id TEXT NOT NULL,
status TEXT NOT NULL,
started_at INTEGER NOT NULL,
last_seen_at INTEGER NOT NULL,
ended_at INTEGER NULL,
record_started_at INTEGER NULL,
session_id TEXT NULL,
participants TEXT NOT NULL DEFAULT '[]',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at INTEGER NOT NULL DEFAULT 0,
error TEXT NULL,
storage_path TEXT NULL,
audio_url TEXT NULL,
page_id TEXT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS calls_status ON calls(status);
""";
cmd.ExecuteNonQuery();
}

View File

@@ -43,7 +43,20 @@ public sealed class JitsiToken
["iat"] = now.ToUnixTimeSeconds(),
["nbf"] = now.ToUnixTimeSeconds() - 30,
["exp"] = exp,
["context"] = new Dictionary<string, object?> { ["user"] = user },
["context"] = new Dictionary<string, object?>
{
["user"] = user,
// Голосовые звонки: без демонстрации экрана; запись запускает сервер, не участники.
["features"] = new Dictionary<string, object?>
{
["screen-sharing"] = false,
["recording"] = false,
["livestreaming"] = false,
["transcription"] = false,
["outbound-call"] = false,
["sip-outbound-call"] = false,
},
},
["moderator"] = moderator,
};
return new Pass(Sign(payload), exp);

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using Calls.Api;
using Calls.Api.Recording;
var builder = WebApplication.CreateBuilder(args);
@@ -8,6 +9,25 @@ builder.Services.AddSingleton<JitsiToken>();
builder.Services.AddSingleton<Store>();
builder.Services.AddSingleton<SupabaseAuth>();
builder.Services.AddHttpClient("supabase", c => c.Timeout = TimeSpan.FromSeconds(10));
// Запись и транскрипция (см. README «Запис і транскрипція»).
builder.Services.AddSingleton(sp => RecordingOptions.From(sp.GetRequiredService<IConfiguration>()));
builder.Services.AddSingleton<CallGate>();
builder.Services.AddSingleton<CallsRepo>();
builder.Services.AddSingleton<ProsodyRooms>();
builder.Services.AddSingleton<JibriClient>();
builder.Services.AddSingleton<Transcriber>();
builder.Services.AddSingleton<CrmSession>();
builder.Services.AddSingleton<AudioStorage>();
builder.Services.AddSingleton<WikiPublisher>();
builder.Services.AddHttpClient("jitsi", c => c.Timeout = TimeSpan.FromSeconds(5));
builder.Services.AddHttpClient("openai", c => c.Timeout = TimeSpan.FromMinutes(10));
builder.Services.AddHttpClient("storage", c => c.Timeout = TimeSpan.FromMinutes(5));
builder.Services.AddHttpClient("wiki", c => c.Timeout = TimeSpan.FromSeconds(30));
if (!string.Equals(builder.Configuration["CALLS_BACKGROUND"], "false", StringComparison.OrdinalIgnoreCase))
{
builder.Services.AddHostedService<CallMonitor>();
builder.Services.AddHostedService<RecordingProcessor>();
}
var app = builder.Build();
var cfg = app.Configuration;
@@ -171,7 +191,7 @@ app.MapPost("/api/room/guest-link", (HttpContext ctx, Store store) =>
app.MapPost("/api/join", (HttpContext ctx, Store store) =>
{
var ws = Ws(ctx);
store.Room(ws, U(ctx).Id);
store.CheckGate(ws, U(ctx).Id); // лимит одновременных звонков: сразу сказать, а не после открытия окна
var (code, expiresIn) = store.CreateJoinCode(ws, U(ctx));
return Results.Json(new { code, expiresIn, url = $"{publicUrl}/call?code={code}" });
});

View File

@@ -0,0 +1,76 @@
using System.Collections.Concurrent;
namespace Calls.Api.Recording;
/// <summary>
/// Лимит одновременных звонков (MAX_CONCURRENT_CALLS). Звонок занят, пока в его комнате есть участники
/// (данные монитора из prosody), плюс короткая бронь комнаты после выдачи пропуска — человек ещё подключается.
/// В уже идущий звонок пускаем всегда. Если данных о занятости нет (prosody недоступен) — не блокируем.
/// </summary>
public sealed class CallGate(RecordingOptions opt, TimeProvider? time = null, ILogger<CallGate>? log = null)
{
public const string BusyMessage = "Зараз уже йде інший дзвінок, спробуйте пізніше.";
public static readonly TimeSpan Reservation = TimeSpan.FromMinutes(2);
public static readonly TimeSpan SnapshotMaxAge = TimeSpan.FromSeconds(30);
private readonly TimeProvider _time = time ?? TimeProvider.System;
private readonly ConcurrentDictionary<string, DateTimeOffset> _reserved = new();
private readonly Lock _lock = new();
private Dictionary<string, int> _occupied = [];
private DateTimeOffset _snapshotAt = DateTimeOffset.MinValue;
private DateTimeOffset _warnedAt = DateTimeOffset.MinValue;
/// <summary>Монитор: занятость комнат (только комнаты с участниками).</summary>
public void Update(Dictionary<string, int> occupied)
{
lock (_lock)
{
// Звонок закончился (была занята — стала пуста): бронь больше не нужна, место свободно сразу.
foreach (var (room, n) in _occupied)
if (n > 0 && !occupied.ContainsKey(room)) _reserved.TryRemove(room, out _);
_occupied = occupied;
_snapshotAt = _time.GetUtcNow();
}
}
public IReadOnlyCollection<string> ActiveRooms()
{
lock (_lock) return Active(_time.GetUtcNow());
}
private HashSet<string> Active(DateTimeOffset now)
{
var set = new HashSet<string>(_occupied.Where(kv => kv.Value > 0).Select(kv => kv.Key));
foreach (var (room, until) in _reserved)
if (until > now) set.Add(room); else _reserved.TryRemove(room, out _);
return set;
}
/// <summary>Можно ли войти в комнату (без брони) — проверка перед «Приєднатися».</summary>
public bool CanEnter(string room) => Decide(room, reserve: false);
/// <summary>Выдача пропуска: можно ли войти; если да — комната бронируется на пару минут.</summary>
public bool TryEnter(string room) => Decide(room, reserve: true);
private bool Decide(string room, bool reserve)
{
lock (_lock)
{
var now = _time.GetUtcNow();
if (now - _snapshotAt > SnapshotMaxAge)
{
if (now - _warnedAt > TimeSpan.FromMinutes(1))
{
log?.LogWarning("Немає свіжих даних про зайнятість кімнат (prosody), ліміт дзвінків не застосовується");
_warnedAt = now;
}
if (reserve) _reserved[room] = now + Reservation;
return true;
}
var active = Active(now);
var ok = active.Contains(room) || active.Count < opt.MaxConcurrentCalls;
if (ok && reserve) _reserved[room] = now + Reservation;
return ok;
}
}
}

View File

@@ -0,0 +1,132 @@
namespace Calls.Api.Recording;
/// <summary>
/// Раз в несколько секунд опрашивает prosody: кто в комнатах. Отсюда:
/// • занятость для лимита звонков (CallGate);
/// • начало звонка (≥ RECORD_MIN_PARTICIPANTS участников) → Jibri начинает запись;
/// • конец звонка (комната пуста дольше EmptyGrace) → Jibri останавливается, запись уходит в обработку;
/// • список участников для страницы в вики.
/// Во время записи пишет в журнал память, своп и загрузку хоста (HostStats).
/// </summary>
public sealed class CallMonitor(
ProsodyRooms prosody, JibriClient jibri, CallGate gate, CallsRepo calls, Store store,
RecordingOptions opt, ILogger<CallMonitor> log) : BackgroundService
{
private readonly HostStats _host = new();
private DateTimeOffset _statsAt = DateTimeOffset.MinValue;
private int _jibriIdleTicks;
private bool _prosodyDown;
protected override async Task ExecuteAsync(CancellationToken ct)
{
log.LogInformation("Монітор дзвінків: prosody {P}, jibri {J}, запис {E}, MAX_CONCURRENT_CALLS={M}, MIN_CALL_SECONDS={S}",
opt.ProsodyUrl, opt.JibriUrl, opt.Enabled ? "увімкнено" : "вимкнено", opt.MaxConcurrentCalls, opt.MinCallSeconds);
log.LogInformation("Jibri: {S}", await jibri.StatusAsync(ct));
while (!ct.IsCancellationRequested)
{
try { await TickAsync(ct); }
catch (Exception e) when (e is not OperationCanceledException) { log.LogError(e, "Монітор дзвінків: помилка"); }
try { await Task.Delay(opt.PollInterval, ct); } catch (OperationCanceledException) { break; }
}
}
public async Task TickAsync(CancellationToken ct)
{
var rooms = new HashSet<string>(store.RecentRooms(TimeSpan.FromHours(13)));
foreach (var c in calls.AllOpen()) rooms.Add(c.Room);
var occupied = new Dictionary<string, int>();
var ok = true;
foreach (var room in rooms)
{
var names = await prosody.ParticipantsAsync(room, ct);
if (names == null) { ok = false; continue; }
if (names.Count > 0) occupied[room] = names.Count;
await TrackAsync(room, names, ct);
}
if (ok) gate.Update(occupied);
if (!ok && !_prosodyDown) log.LogWarning("Prosody недоступний ({P}): зайнятість кімнат невідома", opt.ProsodyUrl);
if (ok && _prosodyDown) log.LogInformation("Prosody знову доступний");
_prosodyDown = !ok;
if (calls.AllOpen().Any(c => c.Status == "recording") && DateTimeOffset.UtcNow - _statsAt > TimeSpan.FromSeconds(15))
{
_statsAt = DateTimeOffset.UtcNow;
log.LogInformation("Ресурси хоста під час запису: {S}", _host.Sample());
}
}
private async Task TrackAsync(string room, List<string> names, CancellationToken ct)
{
var call = calls.Open(room);
if (names.Count > 0)
{
if (call == null)
{
if (names.Count < opt.RecordMinParticipants) return; // один человек ждёт — ещё не звонок
var ws = store.WorkspaceOfRoom(room);
if (ws == null) return;
call = calls.Create(room, ws);
log.LogInformation("Дзвінок {C} почався: кімната {R}, учасників {N}", call.Id, room, names.Count);
}
calls.Seen(call.Id, Merge(call, names));
if (call.Status == "active" && opt.Enabled) await TryStartAsync(call, ct);
else if (call.Status == "recording") await WatchJibriAsync(call, ct);
return;
}
// комната пуста
if (call == null) return;
if (calls.Now - call.LastSeenAt < (long)opt.EmptyGrace.TotalSeconds) return;
await EndAsync(call, ct, "усі вийшли");
}
private async Task TryStartAsync(CallRow call, CancellationToken ct)
{
var st = await jibri.StatusAsync(ct);
if (st != JibriClient.Busy.Idle)
{
log.LogWarning("Дзвінок {C}: Jibri {S}, запис поки не почато", call.Id, st);
return;
}
var session = call.Id;
var (ok, detail) = await jibri.StartAsync(session, call.Room, ct);
if (!ok) { log.LogError("Дзвінок {C}: Jibri не почав запис: {D}", call.Id, detail); return; }
calls.RecordingStarted(call.Id, session);
_jibriIdleTicks = 0;
log.LogInformation("Дзвінок {C}: запис почато (Jibri, сесія {S}); ресурси: {H}", call.Id, session, _host.Sample());
}
/// <summary>Jibri сам перестал писать (сбой) при живом звонке: закрываем этот кусок, следующий тик начнёт новый.</summary>
private async Task WatchJibriAsync(CallRow call, CancellationToken ct)
{
var st = await jibri.StatusAsync(ct);
_jibriIdleTicks = st == JibriClient.Busy.Idle ? _jibriIdleTicks + 1 : 0;
if (_jibriIdleTicks >= 3)
{
log.LogWarning("Дзвінок {C}: Jibri перестав записувати під час дзвінка — закриваю цей запис, почну новий", call.Id);
await EndAsync(call, ct, "Jibri зупинився");
}
}
private async Task EndAsync(CallRow call, CancellationToken ct, string why)
{
var recording = call.Status == "recording";
if (recording && !await jibri.StopAsync(ct))
log.LogWarning("Дзвінок {C}: Jibri не відповів на stopService (запис зупиниться сам)", call.Id);
calls.Ended(call.Id, call.LastSeenAt, recording);
log.LogInformation("Дзвінок {C} закінчився ({W}): тривалість {D} с, запис: {R}; ресурси: {H}",
call.Id, why, call.LastSeenAt - call.StartedAt, recording ? "є" : "немає", _host.Sample());
}
/// <summary>Все, кто был в звонке; гости — с пометкой (по выданным гостевым пропускам этой комнаты).</summary>
private List<Participant> Merge(CallRow call, List<string> names)
{
var list = new List<Participant>(call.Participants);
var known = new HashSet<string>(list.Select(p => p.Name), StringComparer.Ordinal);
var fresh = names.Where(n => !known.Contains(n)).Distinct().ToList();
if (fresh.Count == 0) return list;
var guests = store.GuestNames(call.Room, call.StartedAt - 13 * 3600);
foreach (var n in fresh) list.Add(new Participant(n, guests.Contains(n)));
return list;
}
}

View File

@@ -0,0 +1,110 @@
using System.Security.Cryptography;
using System.Text.Json;
using Microsoft.Data.Sqlite;
namespace Calls.Api.Recording;
public sealed record Participant(string Name, bool Guest);
public sealed record CallRow(
string Id, string Room, string WorkspaceId, string Status, long StartedAt, long LastSeenAt, long? EndedAt,
long? RecordStartedAt, string? SessionId, List<Participant> Participants, int Attempts, long NextAttemptAt,
string? Error, string? StoragePath, string? AudioUrl, string? PageId);
/// <summary>Таблица calls: звонок, его запись и шаги обработки (чтобы повторы не делали работу дважды).</summary>
public sealed class CallsRepo(Db db, TimeProvider? time = null)
{
private readonly TimeProvider _time = time ?? TimeProvider.System;
public long Now => _time.GetUtcNow().ToUnixTimeSeconds();
public static readonly string[] OpenStatuses = ["active", "recording"];
public static readonly string[] PendingStatuses = ["ended", "processing", "waiting", "error"];
public CallRow? Open(string room) =>
Query("SELECT * FROM calls WHERE room=$r AND status IN ('active','recording') ORDER BY started_at DESC LIMIT 1", ("$r", room)).FirstOrDefault();
public List<CallRow> AllOpen() => Query("SELECT * FROM calls WHERE status IN ('active','recording')");
public List<CallRow> DuePending(long now) =>
Query("SELECT * FROM calls WHERE status IN ('ended','processing','waiting','error') AND next_attempt_at <= $t ORDER BY started_at", ("$t", now));
public CallRow? Get(string id) => Query("SELECT * FROM calls WHERE id=$i", ("$i", id)).FirstOrDefault();
public CallRow Create(string room, string workspaceId)
{
var id = "c" + Convert.ToHexString(RandomNumberGenerator.GetBytes(10)).ToLowerInvariant();
Exec("""
INSERT INTO calls(id, room, workspace_id, status, started_at, last_seen_at, updated_at)
VALUES ($i,$r,$w,'active',$t,$t,$t)
""", ("$i", id), ("$r", room), ("$w", workspaceId), ("$t", Now));
return Get(id)!;
}
public void Seen(string id, List<Participant> participants) =>
Exec("UPDATE calls SET last_seen_at=$t, participants=$p, updated_at=$t WHERE id=$i",
("$i", id), ("$t", Now), ("$p", JsonSerializer.Serialize(participants)));
public void RecordingStarted(string id, string sessionId) =>
Exec("UPDATE calls SET status='recording', session_id=$s, record_started_at=$t, updated_at=$t WHERE id=$i",
("$i", id), ("$s", sessionId), ("$t", Now));
/// <summary>Конец звонка: с записью — ждём обработку, без записи — нечего обрабатывать.</summary>
public void Ended(string id, long endedAt, bool hadRecording) =>
Exec("UPDATE calls SET status=$st, ended_at=$e, next_attempt_at=0, updated_at=$t WHERE id=$i",
("$i", id), ("$st", hadRecording ? "ended" : "skipped"), ("$e", endedAt), ("$t", Now));
public void SetStatus(string id, string status, string? error = null) =>
Exec("UPDATE calls SET status=$s, error=$er, updated_at=$t WHERE id=$i",
("$i", id), ("$s", status), ("$er", (object?)error ?? DBNull.Value), ("$t", Now));
/// <summary>Ошибка шага: файлы остаются, следующий повтор через растущую паузу (1, 2, 5, 10, 30, 60 мин).</summary>
public void Failed(string id, string error, string status = "error")
{
var row = Get(id);
var attempts = (row?.Attempts ?? 0) + 1;
int[] mins = [1, 2, 5, 10, 30, 60];
var delay = mins[Math.Min(attempts - 1, mins.Length - 1)] * 60;
Exec("UPDATE calls SET status=$s, error=$er, attempts=$a, next_attempt_at=$n, updated_at=$t WHERE id=$i",
("$i", id), ("$s", status), ("$er", error), ("$a", attempts), ("$n", Now + delay), ("$t", Now));
}
/// <summary>Ждём (файлы ещё пишутся, нет настроек): без счётчика ошибок.</summary>
public void Later(string id, string status, string? note, int seconds) =>
Exec("UPDATE calls SET status=$s, error=$er, next_attempt_at=$n, updated_at=$t WHERE id=$i",
("$i", id), ("$s", status), ("$er", (object?)note ?? DBNull.Value), ("$n", Now + seconds), ("$t", Now));
public void SetStoragePath(string id, string path) => Exec("UPDATE calls SET storage_path=$p WHERE id=$i", ("$i", id), ("$p", path));
public void SetAudioUrl(string id, string url) => Exec("UPDATE calls SET audio_url=$u WHERE id=$i", ("$i", id), ("$u", url));
public void SetPageId(string id, string pageId) => Exec("UPDATE calls SET page_id=$p WHERE id=$i", ("$i", id), ("$p", pageId));
public void Done(string id) =>
Exec("UPDATE calls SET status='done', error=NULL, updated_at=$t WHERE id=$i", ("$i", id), ("$t", Now));
private List<CallRow> Query(string sql, params (string, object)[] ps)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v);
using var r = cmd.ExecuteReader();
var list = new List<CallRow>();
while (r.Read())
{
string? S(string col) => r.IsDBNull(r.GetOrdinal(col)) ? null : r.GetString(r.GetOrdinal(col));
long? L(string col) => r.IsDBNull(r.GetOrdinal(col)) ? null : r.GetInt64(r.GetOrdinal(col));
list.Add(new CallRow(S("id")!, S("room")!, S("workspace_id")!, S("status")!, L("started_at")!.Value, L("last_seen_at")!.Value,
L("ended_at"), L("record_started_at"), S("session_id"),
JsonSerializer.Deserialize<List<Participant>>(S("participants") ?? "[]") ?? [],
(int)L("attempts")!.Value, L("next_attempt_at")!.Value, S("error"), S("storage_path"), S("audio_url"), S("page_id")));
}
return list;
}
private void Exec(string sql, params (string, object)[] ps)
{
using var c = db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
foreach (var (k, v) in ps) cmd.Parameters.AddWithValue(k, v);
cmd.ExecuteNonQuery();
}
}

View File

@@ -0,0 +1,33 @@
using System.Globalization;
namespace Calls.Api.Recording;
/// <summary>
/// Память, своп, загрузка и CPU хоста из /proc (контейнер видит общие цифры хоста) — для журнала во время записи.
/// </summary>
public sealed class HostStats
{
private (long Busy, long Total)? _cpuPrev;
public string Sample()
{
try
{
var mem = File.ReadAllLines("/proc/meminfo")
.Select(l => l.Split(':', 2)).Where(p => p.Length == 2)
.ToDictionary(p => p[0], p => long.Parse(p[1].Trim().Split(' ')[0], CultureInfo.InvariantCulture) / 1024);
var load = File.ReadAllText("/proc/loadavg").Split(' ');
var cpu = File.ReadLines("/proc/stat").First().Split(' ', StringSplitOptions.RemoveEmptyEntries).Skip(1)
.Select(x => long.Parse(x, CultureInfo.InvariantCulture)).ToArray();
long idle = cpu[3] + (cpu.Length > 4 ? cpu[4] : 0), total = cpu.Take(8).Sum();
string cpuPct = "—";
if (_cpuPrev is { } p && total > p.Total)
cpuPct = (100.0 * ((total - idle) - p.Busy) / (total - p.Total)).ToString("0", CultureInfo.InvariantCulture) + "%";
_cpuPrev = (total - idle, total);
long M(string k) => mem.TryGetValue(k, out var v) ? v : 0;
return $"пам'ять доступно {M("MemAvailable")} з {M("MemTotal")} МБ, своп зайнято {M("SwapTotal") - M("SwapFree")} з {M("SwapTotal")} МБ, " +
$"CPU {cpuPct}, load {load[0]} {load[1]} {load[2]}";
}
catch (Exception e) { return "недоступно: " + e.Message; }
}
}

View File

@@ -0,0 +1,85 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
namespace Calls.Api.Recording;
/// <summary>Участники комнаты из prosody (mod_muc_size: GET /room?room=…&amp;domain=…), сеть crm-calls-bridge.</summary>
public sealed class ProsodyRooms(IHttpClientFactory http, RecordingOptions opt)
{
/// <summary>Имена участников (без focus и без скрытого рекордера — у них пустой ник). null — prosody недоступен.</summary>
public async Task<List<string>?> ParticipantsAsync(string room, CancellationToken ct)
{
try
{
using var req = new HttpRequestMessage(HttpMethod.Get,
$"{opt.ProsodyUrl}/room?room={Uri.EscapeDataString(room)}&domain={Uri.EscapeDataString(opt.XmppDomain)}");
// Маршрут mod_muc_size висит на виртуальном хосте XMPP-домена.
req.Headers.Host = opt.XmppDomain;
using var r = await http.CreateClient("jitsi").SendAsync(req, ct);
if (r.StatusCode == HttpStatusCode.NotFound) return []; // комнаты нет — пусто
if (!r.IsSuccessStatusCode) return null;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
if (doc.RootElement.ValueKind != JsonValueKind.Array) return null;
var names = new List<string>();
foreach (var o in doc.RootElement.EnumerateArray())
{
var name = o.TryGetProperty("display_name", out var n) && n.ValueKind == JsonValueKind.String ? n.GetString()!.Trim() : "";
if (name.Length > 0) names.Add(name);
}
return names;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException) { return null; }
}
}
/// <summary>
/// Jibri HTTP API (http://jitsi-jibri:2222/jibri/api/v1.0). Запись в файл под скрытым пользователем recorder,
/// каталог записи — RECORDINGS_DIR/&lt;sessionId&gt;.
/// </summary>
public sealed class JibriClient(IHttpClientFactory http, RecordingOptions opt)
{
public enum Busy { Idle, Busy, Unknown }
public async Task<Busy> StatusAsync(CancellationToken ct)
{
try
{
using var r = await http.CreateClient("jitsi").GetAsync($"{opt.JibriUrl}/jibri/api/v1.0/health", ct);
if (!r.IsSuccessStatusCode) return Busy.Unknown;
using var doc = JsonDocument.Parse(await r.Content.ReadAsStringAsync(ct));
var s = doc.RootElement.GetProperty("status").GetProperty("busyStatus").GetString();
return s == "IDLE" ? Busy.Idle : s == "BUSY" ? Busy.Busy : Busy.Unknown;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or JsonException or KeyNotFoundException or InvalidOperationException)
{ return Busy.Unknown; }
}
/// <summary>true — Jibri принял запрос (дальше он асинхронно заходит в комнату).</summary>
public async Task<(bool Ok, string Detail)> StartAsync(string sessionId, string room, CancellationToken ct)
{
var body = new
{
sessionId,
callParams = new { callUrlInfo = new { baseUrl = opt.MeetBaseUrl, callName = room } },
callLoginParams = new { domain = opt.RecorderDomain, username = opt.RecorderUser, password = opt.RecorderPassword },
sinkType = "file",
};
try
{
using var r = await http.CreateClient("jitsi").PostAsJsonAsync($"{opt.JibriUrl}/jibri/api/v1.0/startService", body, ct);
return (r.IsSuccessStatusCode, $"{(int)r.StatusCode} {await r.Content.ReadAsStringAsync(ct)}".Trim());
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return (false, e.Message); }
}
public async Task<bool> StopAsync(CancellationToken ct)
{
try
{
using var r = await http.CreateClient("jitsi").PostAsync($"{opt.JibriUrl}/jibri/api/v1.0/stopService", null, ct);
return r.IsSuccessStatusCode;
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException) { return false; }
}
}

View File

@@ -0,0 +1,206 @@
using System.Globalization;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text;
using System.Text.Json;
namespace Calls.Api.Recording;
public sealed class StepError(string message, bool permanent = false) : Exception(message)
{
/// <summary>Ошибка, которую сам повтор не исправит (нет доступа, нет настроек) — всё равно повторяем, но реже.</summary>
public bool Permanent { get; } = permanent;
}
public sealed record Segment(double Start, double End, string Text);
public sealed record Transcript(string? Language, List<Segment> Segments);
public sealed record AudioPart(string File, double Duration, long Bytes);
/// <summary>Whisper (OpenAI): части по очереди, тайминги каждой части сдвигаются на длительность предыдущих.</summary>
public sealed class Transcriber(IHttpClientFactory http, RecordingOptions opt)
{
public async Task<Transcript> TranscribeAsync(string dir, IReadOnlyList<AudioPart> parts, string mime, CancellationToken ct)
{
var all = new List<Segment>();
string? lang = null;
double offset = 0;
foreach (var part in parts)
{
var path = Path.Combine(dir, part.File);
var size = new FileInfo(path).Length;
if (size > opt.MaxPartBytes) throw new StepError($"частина {part.File} {size / 1048576.0:0.0} МБ більша за ліміт {opt.MaxPartBytes / 1048576} МБ");
var (l, segs) = await OneAsync(path, mime, ct);
lang ??= l;
all.AddRange(segs.Select(s => s with { Start = s.Start + offset, End = s.End + offset }));
offset += part.Duration;
}
return new Transcript(lang, all);
}
private async Task<(string? Lang, List<Segment> Segs)> OneAsync(string path, string mime, CancellationToken ct)
{
await using var fs = File.OpenRead(path);
using var form = new MultipartFormDataContent();
var file = new StreamContent(fs);
file.Headers.ContentType = new MediaTypeHeaderValue(mime);
form.Add(file, "file", Path.GetFileName(path));
form.Add(new StringContent(opt.OpenAiModel), "model");
form.Add(new StringContent("verbose_json"), "response_format");
form.Add(new StringContent("segment"), "timestamp_granularities[]");
using var req = new HttpRequestMessage(HttpMethod.Post, $"{opt.OpenAiBaseUrl}/audio/transcriptions") { Content = form };
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", opt.OpenAiKey);
using var r = await http.CreateClient("openai").SendAsync(req, ct);
var body = await r.Content.ReadAsStringAsync(ct);
if (!r.IsSuccessStatusCode)
throw new StepError($"OpenAI {(int)r.StatusCode}: {Short(body)}", permanent: r.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
if (!root.TryGetProperty("segments", out var segs) || segs.ValueKind != JsonValueKind.Array)
throw new StepError($"модель {opt.OpenAiModel} не повернула сегменти з таймінгами (потрібна whisper-1)", permanent: true);
var list = new List<Segment>();
foreach (var s in segs.EnumerateArray())
{
var text = s.GetProperty("text").GetString()?.Trim() ?? "";
if (text.Length > 0) list.Add(new Segment(s.GetProperty("start").GetDouble(), s.GetProperty("end").GetDouble(), text));
}
return (root.TryGetProperty("language", out var l) ? l.GetString() : null, list);
}
internal static string Short(string s) => s.Length > 300 ? s[..300] + "…" : s;
}
/// <summary>Пользователь CRM из env: вход в Supabase (password grant), токен кэшируется до истечения.</summary>
public sealed class CrmSession(IHttpClientFactory http, RecordingOptions opt)
{
private string? _token;
private DateTimeOffset _until;
public async Task<string> TokenAsync(CancellationToken ct, bool force = false)
{
if (!force && _token != null && _until > DateTimeOffset.UtcNow.AddMinutes(1)) return _token;
using var req = new HttpRequestMessage(HttpMethod.Post, $"{opt.SupabaseUrl}/auth/v1/token?grant_type=password")
{
Content = JsonContent.Create(new { email = opt.CrmUserEmail, password = opt.CrmUserPassword }),
};
req.Headers.Add("apikey", opt.SupabaseKey);
using var r = await http.CreateClient("supabase").SendAsync(req, ct);
var body = await r.Content.ReadAsStringAsync(ct);
if (!r.IsSuccessStatusCode)
throw new StepError($"вхід користувача CRM ({opt.CrmUserEmail}) не вдався: {(int)r.StatusCode}", permanent: (int)r.StatusCode is 400 or 401);
using var doc = JsonDocument.Parse(body);
_token = doc.RootElement.GetProperty("access_token").GetString();
_until = DateTimeOffset.UtcNow.AddSeconds(doc.RootElement.TryGetProperty("expires_in", out var e) ? e.GetInt32() : 3600);
return _token!;
}
}
/// <summary>Supabase Storage: загрузка аудио под пользователем CRM. Бакет публичный (как вложения CRM) — ссылка getPublicUrl.</summary>
public sealed class AudioStorage(IHttpClientFactory http, RecordingOptions opt, CrmSession crm)
{
public string PublicUrl(string path) =>
$"{opt.SupabaseUrl}/storage/v1/object/public/{Uri.EscapeDataString(opt.Bucket)}/{string.Join('/', path.Split('/').Select(Uri.EscapeDataString))}";
public async Task UploadAsync(string path, string file, string mime, CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
var token = await crm.TokenAsync(ct, force: attempt > 0);
await using var fs = File.OpenRead(file);
using var req = new HttpRequestMessage(HttpMethod.Post,
$"{opt.SupabaseUrl}/storage/v1/object/{Uri.EscapeDataString(opt.Bucket)}/{string.Join('/', path.Split('/').Select(Uri.EscapeDataString))}")
{ Content = new StreamContent(fs) };
req.Content.Headers.ContentType = new MediaTypeHeaderValue(mime);
req.Headers.Add("apikey", opt.SupabaseKey);
req.Headers.Add("x-upsert", "false");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var r = await http.CreateClient("storage").SendAsync(req, ct);
var body = await r.Content.ReadAsStringAsync(ct);
if (r.IsSuccessStatusCode) return;
// Повтор после того, как прошлый раз загрузка прошла, а ответ потерялся: файл уже есть — это успех.
if (body.Contains("Duplicate", StringComparison.OrdinalIgnoreCase) || body.Contains("already exists", StringComparison.OrdinalIgnoreCase)) return;
if (r.StatusCode == HttpStatusCode.Unauthorized && attempt == 0) continue;
throw new StepError($"Supabase Storage {(int)r.StatusCode}: {Transcriber.Short(body)}",
permanent: r.StatusCode is HttpStatusCode.Forbidden || body.Contains("row-level security", StringComparison.OrdinalIgnoreCase));
}
}
}
/// <summary>Вики CRM (REST API wiki-crm): папка «Дзвінки» в корне workspace и страница звонка — от пользователя CRM.</summary>
public sealed class WikiPublisher(IHttpClientFactory http, RecordingOptions opt, CrmSession crm)
{
public async Task<string> PublishAsync(string workspaceId, string title, string markdown, CancellationToken ct)
{
var tree = await SendAsync(HttpMethod.Get, "/api/tree", workspaceId, null, ct);
string? folderId = null, existing = null;
foreach (var n in tree.GetProperty("nodes").EnumerateArray())
if (n.GetProperty("kind").GetString() == "folder" && n.GetProperty("parentId").ValueKind == JsonValueKind.Null
&& n.GetProperty("title").GetString() == opt.WikiFolder) { folderId = n.GetProperty("id").GetString(); break; }
if (folderId == null)
folderId = (await SendAsync(HttpMethod.Post, "/api/folders", workspaceId, new { title = opt.WikiFolder }, ct)).GetProperty("id").GetString();
else
foreach (var n in tree.GetProperty("nodes").EnumerateArray())
// Повтор после потерянного ответа: такая страница в папке уже есть.
if (n.GetProperty("kind").GetString() == "page" && n.GetProperty("parentId").GetString() == folderId
&& n.GetProperty("title").GetString() == title) { existing = n.GetProperty("id").GetString(); break; }
if (existing != null) return existing;
var page = await SendAsync(HttpMethod.Post, "/api/pages", workspaceId, new { title, parentId = folderId, markdown }, ct);
return page.GetProperty("id").GetString()!;
}
private async Task<JsonElement> SendAsync(HttpMethod m, string path, string ws, object? body, CancellationToken ct)
{
for (var attempt = 0; ; attempt++)
{
using var req = new HttpRequestMessage(m, opt.WikiUrl + path);
if (body != null) req.Content = JsonContent.Create(body);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", await crm.TokenAsync(ct, force: attempt > 0));
req.Headers.Add("X-Wiki-Workspace", ws);
using var r = await http.CreateClient("wiki").SendAsync(req, ct);
var text = await r.Content.ReadAsStringAsync(ct);
if (r.IsSuccessStatusCode) return JsonDocument.Parse(text).RootElement.Clone();
if (r.StatusCode == HttpStatusCode.Unauthorized && attempt == 0) continue;
if (r.StatusCode == HttpStatusCode.Forbidden)
throw new StepError($"користувач CRM {opt.CrmUserEmail} не має доступу до воркспейсу {ws} у вікі — додайте його учасником воркспейсу", permanent: true);
throw new StepError($"вікі {m} {path}: {(int)r.StatusCode} {Transcriber.Short(text)}");
}
}
}
/// <summary>Страница звонка в вики (Markdown, украинский).</summary>
public static class CallPage
{
public static string Title(DateTimeOffset start, TimeZoneInfo tz) =>
"Дзвінок " + TimeZoneInfo.ConvertTime(start, tz).ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
public static string Markdown(DateTimeOffset start, double durationSeconds, IEnumerable<Participant> people,
string audioUrl, Transcript t, TimeZoneInfo tz)
{
var local = TimeZoneInfo.ConvertTime(start, tz);
var sb = new StringBuilder();
sb.AppendLine($"**Дата і час:** {local.ToString("dd.MM.yyyy HH:mm", CultureInfo.InvariantCulture)} ({tz.Id}) ");
sb.AppendLine($"**Тривалість:** {Duration(durationSeconds)} ");
var names = people.Select(p => p.Guest ? $"{p.Name} (гість)" : p.Name).ToList();
sb.AppendLine($"**Учасники:** {(names.Count > 0 ? string.Join(", ", names) : "—")} ");
sb.AppendLine($"**Аудіо:** [запис дзвінка]({audioUrl})");
sb.AppendLine();
sb.AppendLine("## Транскрипція");
sb.AppendLine();
if (t.Segments.Count == 0) sb.AppendLine("_Мовлення не розпізнано._");
foreach (var s in t.Segments)
sb.AppendLine($"[{Clock(s.Start)}] {s.Text.Replace("\n", " ").Trim()} ");
return sb.ToString();
}
public static string Clock(double seconds)
{
var ts = TimeSpan.FromSeconds(Math.Max(0, Math.Floor(seconds)));
return $"{(int)ts.TotalHours:00}:{ts.Minutes:00}:{ts.Seconds:00}";
}
public static string Duration(double seconds)
{
var ts = TimeSpan.FromSeconds(Math.Round(seconds));
return ts.TotalHours >= 1 ? $"{(int)ts.TotalHours} год {ts.Minutes} хв {ts.Seconds} с" : $"{ts.Minutes} хв {ts.Seconds} с";
}
}

View File

@@ -0,0 +1,79 @@
namespace Calls.Api.Recording;
/// <summary>
/// Настройки записи и транскрипции. Имена — как переменные окружения Coolify (см. README «Запис і транскрипція»).
/// Без OPENAI_API_KEY или CRM_USER_EMAIL/CRM_USER_PASSWORD запись делается, но обработка ждёт настроек
/// (в CRM и вики ничего не пишется, файлы остаются на диске).
/// </summary>
public sealed class RecordingOptions
{
public bool Enabled { get; init; } = true;
public int MaxConcurrentCalls { get; init; } = 1;
public int MinCallSeconds { get; init; } = 60;
/// <summary>С какого числа участников начинается звонок и запись (один человек в комнате — ещё не звонок).</summary>
public int RecordMinParticipants { get; init; } = 2;
/// <summary>Сколько комната должна быть пустой, чтобы звонок считался законченным (переподключения).</summary>
public TimeSpan EmptyGrace { get; init; } = TimeSpan.FromSeconds(20);
public TimeSpan PollInterval { get; init; } = TimeSpan.FromSeconds(5);
public string ProsodyUrl { get; init; } = "http://jitsi-prosody:5280";
public string XmppDomain { get; init; } = "meet.jitsi";
public string JibriUrl { get; init; } = "http://jitsi-jibri:2222";
public string MeetBaseUrl { get; init; } = "https://meet.volcanic.tes.gd";
public string RecorderDomain { get; init; } = "hidden.meet.jitsi";
public string RecorderUser { get; init; } = "recorder";
public string RecorderPassword { get; init; } = "";
public string RecordingsDir { get; init; } = "/recordings";
public string OpenAiKey { get; init; } = "";
public string OpenAiModel { get; init; } = "whisper-1";
public string OpenAiBaseUrl { get; init; } = "https://api.openai.com/v1";
public long MaxPartBytes { get; init; } = 25L * 1024 * 1024;
public string SupabaseUrl { get; init; } = "";
public string SupabaseKey { get; init; } = "";
public string CrmUserEmail { get; init; } = "";
public string CrmUserPassword { get; init; } = "";
public string Bucket { get; init; } = "files";
public string Prefix { get; init; } = "calls/";
public string WikiUrl { get; init; } = "https://wiki-crm.tes.gd";
public string WikiFolder { get; init; } = "Дзвінки";
public string TimeZoneId { get; init; } = "Europe/Warsaw";
public bool HasOpenAi => OpenAiKey.Length > 0;
public bool HasCrmUser => CrmUserEmail.Length > 0 && CrmUserPassword.Length > 0;
public static RecordingOptions From(IConfiguration c)
{
static string S(IConfiguration c, string k, string d) => string.IsNullOrWhiteSpace(c[k]) ? d : c[k]!.Trim();
static int I(IConfiguration c, string k, int d) => int.TryParse(c[k], out var v) && v >= 0 ? v : d;
var prefix = S(c, "RECORDINGS_PREFIX", "calls/").TrimStart('/');
if (prefix.Length > 0 && !prefix.EndsWith('/')) prefix += "/";
return new RecordingOptions
{
Enabled = !string.Equals(c["RECORDING_ENABLED"], "false", StringComparison.OrdinalIgnoreCase),
MaxConcurrentCalls = Math.Max(1, I(c, "MAX_CONCURRENT_CALLS", 1)),
MinCallSeconds = I(c, "MIN_CALL_SECONDS", 60),
RecordMinParticipants = Math.Max(1, I(c, "RECORD_MIN_PARTICIPANTS", 2)),
ProsodyUrl = S(c, "PROSODY_URL", "http://jitsi-prosody:5280").TrimEnd('/'),
JibriUrl = S(c, "JIBRI_URL", "http://jitsi-jibri:2222").TrimEnd('/'),
MeetBaseUrl = "https://" + S(c, "Jitsi:Domain", "meet.volcanic.tes.gd"),
RecorderPassword = S(c, "JIBRI_RECORDER_PASSWORD", ""),
RecordingsDir = S(c, "RECORDINGS_DIR", "/recordings"),
OpenAiKey = S(c, "OPENAI_API_KEY", ""),
OpenAiModel = S(c, "OPENAI_TRANSCRIBE_MODEL", "whisper-1"),
OpenAiBaseUrl = S(c, "OPENAI_BASE_URL", "https://api.openai.com/v1").TrimEnd('/'),
MaxPartBytes = I(c, "TRANSCRIBE_MAX_PART_MB", 25) * 1024L * 1024,
SupabaseUrl = S(c, "Supabase:Url", "").TrimEnd('/'),
SupabaseKey = S(c, "Supabase:PublishableKey", ""),
CrmUserEmail = S(c, "CRM_USER_EMAIL", ""),
CrmUserPassword = c["CRM_USER_PASSWORD"] ?? "",
Bucket = S(c, "RECORDINGS_BUCKET", "files"),
Prefix = prefix,
WikiUrl = S(c, "WIKI_URL", "https://wiki-crm.tes.gd").TrimEnd('/'),
WikiFolder = S(c, "WIKI_CALLS_FOLDER", "Дзвінки"),
TimeZoneId = S(c, "CALLS_TIME_ZONE", "Europe/Warsaw"),
};
}
}

View File

@@ -0,0 +1,152 @@
using System.Globalization;
using System.Security.Cryptography;
using System.Text.Json;
namespace Calls.Api.Recording;
/// <summary>
/// Обработка законченных записей (каталог RECORDINGS_DIR/&lt;сессия&gt; от Jibri finalize.sh: audio.*, parts/, meta.json, ready).
/// Шаги идемпотентны и сохраняются (transcript.json, storage_path/audio_url, page_id), поэтому повтор после ошибки
/// продолжает с места остановки. Локальные файлы удаляются только после загрузки аудио и создания страницы.
/// </summary>
public sealed class RecordingProcessor(
CallsRepo calls, RecordingOptions opt, Transcriber whisper, AudioStorage storage, WikiPublisher wiki,
ILogger<RecordingProcessor> log, TimeProvider? time = null) : BackgroundService
{
/// <summary>Сколько ждать маркер ready после конца звонка (Jibri дописывает файл, finalize извлекает звук).</summary>
public static readonly TimeSpan FinalizeTimeout = TimeSpan.FromMinutes(30);
private readonly TimeProvider _time = time ?? TimeProvider.System;
private readonly HashSet<string> _waitingLogged = [];
protected override async Task ExecuteAsync(CancellationToken ct)
{
log.LogInformation("Обробка записів: каталог {D}, OpenAI {O}, користувач CRM {U}, бакет {B}/{P}, вікі {W} → «{F}»",
opt.RecordingsDir, opt.HasOpenAi ? $"так ({opt.OpenAiModel})" : "НЕ ЗАДАНО", opt.HasCrmUser ? opt.CrmUserEmail : "НЕ ЗАДАНО",
opt.Bucket, opt.Prefix, opt.WikiUrl, opt.WikiFolder);
while (!ct.IsCancellationRequested)
{
try { await RunOnceAsync(ct); }
catch (Exception e) when (e is not OperationCanceledException) { log.LogError(e, "Обробка записів: помилка"); }
try { await Task.Delay(TimeSpan.FromSeconds(10), ct); } catch (OperationCanceledException) { break; }
}
}
public async Task RunOnceAsync(CancellationToken ct)
{
foreach (var call in calls.DuePending(calls.Now))
{
try { await ProcessAsync(call, ct); }
catch (StepError e)
{
calls.Failed(call.Id, e.Message);
log.LogError("Дзвінок {C}: {E} (файли збережено, повтор пізніше)", call.Id, e.Message);
}
catch (Exception e) when (e is HttpRequestException or TaskCanceledException or IOException or JsonException)
{
if (ct.IsCancellationRequested) throw;
calls.Failed(call.Id, e.Message);
log.LogError("Дзвінок {C}: {T}: {E} (файли збережено, повтор пізніше)", call.Id, e.GetType().Name, e.Message);
}
}
}
private async Task ProcessAsync(CallRow call, CancellationToken ct)
{
var dir = Path.Combine(opt.RecordingsDir, call.SessionId ?? call.Id);
var age = calls.Now - (call.EndedAt ?? call.LastSeenAt);
if (File.Exists(Path.Combine(dir, "failed")))
throw new StepError("finalize.sh не зміг обробити запис: " + File.ReadAllText(Path.Combine(dir, "failed")).Trim());
if (!File.Exists(Path.Combine(dir, "ready")))
{
if (age > FinalizeTimeout.TotalSeconds)
throw new StepError($"немає готового запису в {dir} через {age / 60} хв після дзвінка (див. журнал Jibri / finalize.log)");
calls.Later(call.Id, "ended", "чекаю на запис від Jibri", 10);
return;
}
var meta = JsonDocument.Parse(await File.ReadAllTextAsync(Path.Combine(dir, "meta.json"), ct)).RootElement;
var duration = meta.GetProperty("duration").GetDouble();
var audioFile = meta.GetProperty("audio").GetString()!;
var mime = meta.GetProperty("mime").GetString()!;
if (meta.TryGetProperty("quality", out var q))
log.LogInformation("Дзвінок {C}: запис {D:0} с, {B} КБ, якість: {Q}", call.Id, duration,
meta.GetProperty("bytes").GetInt64() / 1024, q.GetRawText());
// Короткий звонок: без страницы, файлы удаляем.
if (duration < opt.MinCallSeconds)
{
DeleteDir(dir);
calls.SetStatus(call.Id, "skipped", $"коротший за {opt.MinCallSeconds} с ({duration:0} с)");
log.LogInformation("Дзвінок {C}: {D:0} с < MIN_CALL_SECONDS={M} — пропущено, файли видалено", call.Id, duration, opt.MinCallSeconds);
return;
}
// Без ключа OpenAI и пользователя CRM ничего наружу не отправляем: запись ждёт на диске.
if (!opt.HasOpenAi || !opt.HasCrmUser || opt.SupabaseUrl.Length == 0)
{
var missing = string.Join(", ", new[] { opt.HasOpenAi ? null : "OPENAI_API_KEY", opt.HasCrmUser ? null : "CRM_USER_EMAIL/CRM_USER_PASSWORD" }.Where(x => x != null));
calls.Later(call.Id, "waiting", "чекаю налаштувань: " + missing, 300);
if (_waitingLogged.Add(call.Id))
log.LogWarning("Дзвінок {C}: запис готовий, але не задано {M} — обробка чекає, файли на диску", call.Id, missing);
return;
}
calls.SetStatus(call.Id, "processing");
// 1. Транскрипция (сохраняется в transcript.json — повтор её не делает заново).
var tPath = Path.Combine(dir, "transcript.json");
Transcript transcript;
if (File.Exists(tPath)) transcript = JsonSerializer.Deserialize<Transcript>(await File.ReadAllTextAsync(tPath, ct))!;
else
{
var parts = meta.GetProperty("parts").EnumerateArray()
.Select(p => new AudioPart(p.GetProperty("file").GetString()!, p.GetProperty("duration").GetDouble(), p.GetProperty("bytes").GetInt64()))
.ToList();
if (parts.Count == 0) parts.Add(new AudioPart(audioFile, duration, meta.GetProperty("bytes").GetInt64()));
transcript = await whisper.TranscribeAsync(dir, parts, mime, ct);
await File.WriteAllTextAsync(tPath + ".tmp", JsonSerializer.Serialize(transcript), ct);
File.Move(tPath + ".tmp", tPath, overwrite: true);
log.LogInformation("Дзвінок {C}: транскрипція готова — {N} сегментів, мова {L}, частин {P}",
call.Id, transcript.Segments.Count, transcript.Language ?? "?", parts.Count);
}
var tz = TimeZoneInfo.FindSystemTimeZoneById(opt.TimeZoneId);
var start = DateTimeOffset.FromUnixTimeSeconds(call.RecordStartedAt ?? call.StartedAt);
// 2. Аудио в Supabase Storage. Путь сохраняется до загрузки: повтор грузит в тот же адрес.
var path = call.StoragePath;
if (path == null)
{
var local = TimeZoneInfo.ConvertTime(start, tz);
var rnd = Convert.ToHexString(RandomNumberGenerator.GetBytes(12)).ToLowerInvariant();
path = $"{opt.Prefix}{local:yyyy}/{local:MM}/{rnd}/dzvinok-{local.ToString("yyyy-MM-dd-HHmm", CultureInfo.InvariantCulture)}{Path.GetExtension(audioFile)}";
calls.SetStoragePath(call.Id, path);
}
var audioUrl = call.AudioUrl;
if (audioUrl == null)
{
await storage.UploadAsync(path, Path.Combine(dir, audioFile), mime, ct);
audioUrl = storage.PublicUrl(path);
calls.SetAudioUrl(call.Id, audioUrl);
log.LogInformation("Дзвінок {C}: аудіо завантажено ({B}/{P})", call.Id, opt.Bucket, path);
}
// 3. Страница в вики workspace звонка.
if (call.PageId == null)
{
var title = CallPage.Title(start, tz);
var md = CallPage.Markdown(start, duration, call.Participants, audioUrl, transcript, tz);
var pageId = await wiki.PublishAsync(call.WorkspaceId, title, md, ct);
calls.SetPageId(call.Id, pageId);
log.LogInformation("Дзвінок {C}: сторінку «{T}» створено у вікі ({W}, сторінка {P})", call.Id, title, call.WorkspaceId, pageId);
}
// 4. Только теперь — удалить локальные файлы.
DeleteDir(dir);
calls.Done(call.Id);
log.LogInformation("Дзвінок {C}: оброблено, локальні файли видалено", call.Id);
}
private void DeleteDir(string dir)
{
if (Directory.Exists(dir)) Directory.Delete(dir, recursive: true);
}
}

View File

@@ -1,6 +1,7 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.Sqlite;
using Calls.Api.Recording;
namespace Calls.Api;
@@ -21,12 +22,14 @@ public sealed class Store
private readonly TimeSpan _codeTtl;
private readonly TimeSpan _sessionTtl;
private readonly TimeProvider _time;
private readonly CallGate? _gate;
public Store(Db db, JitsiToken jitsi, IConfiguration cfg, TimeProvider? time = null)
public Store(Db db, JitsiToken jitsi, IConfiguration cfg, TimeProvider? time = null, CallGate? gate = null)
{
_db = db;
_jitsi = jitsi;
_time = time ?? TimeProvider.System;
_gate = gate;
var ls = cfg["Calls:LinkSecret"] ?? "";
if (ls.Length < 32) throw new InvalidOperationException("Calls:LinkSecret (GUEST_LINK_SECRET) не задан или короче 32 символов");
_linkSecret = Encoding.UTF8.GetBytes(ls);
@@ -192,10 +195,56 @@ public sealed class Store
email = r.IsDBNull(4) ? null : r.GetString(4);
}
var room = Room(ws, uid);
// Лимит одновременных звонков: в идущий звонок — всегда, в другую комнату — только если есть место.
if (_gate != null && !_gate.TryEnter(room))
throw new CallsError(409, CallGate.BusyMessage);
var pass = _jitsi.Issue(room, uid, name, email, moderator: kind == "member", _time.GetUtcNow());
Exec(c, "INSERT INTO pass_log(room, workspace_id, name, kind, issued_at) VALUES ($r,$w,$n,$k,$t)",
("$r", room), ("$w", ws), ("$n", name), ("$k", kind), ("$t", Now));
return new PassResult(pass.Jwt, pass.Exp, room, _jitsi.Domain, name, kind);
}
/// <summary>Workspace комнаты (для страницы в вики) или null.</summary>
public string? WorkspaceOfRoom(string room)
{
using var c = _db.Open();
return Scalar(c, "SELECT workspace_id FROM rooms WHERE room=$r", ("$r", room));
}
/// <summary>Комнаты, за которыми следит монитор: в них выдавали пропуск за последние hours часов.</summary>
public List<string> RecentRooms(TimeSpan window)
{
using var c = _db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT DISTINCT room FROM pass_log WHERE issued_at >= $t";
cmd.Parameters.AddWithValue("$t", Now - (long)window.TotalSeconds);
using var r = cmd.ExecuteReader();
var list = new List<string>();
while (r.Read()) list.Add(r.GetString(0));
return list;
}
/// <summary>Имена гостей, получавших пропуск в комнату за окно времени (пометка «гість» на странице).</summary>
public HashSet<string> GuestNames(string room, long since)
{
using var c = _db.Open();
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT DISTINCT name FROM pass_log WHERE room=$r AND kind='guest' AND issued_at >= $t";
cmd.Parameters.AddWithValue("$r", room);
cmd.Parameters.AddWithValue("$t", since);
using var r = cmd.ExecuteReader();
var set = new HashSet<string>(StringComparer.Ordinal);
while (r.Read()) set.Add(r.GetString(0));
return set;
}
/// <summary>Проверка лимита перед «Приєднатися» (без брони): workspace → комната → есть ли место.</summary>
public void CheckGate(string workspaceId, string userId)
{
if (_gate != null && !_gate.CanEnter(Room(workspaceId, userId)))
throw new CallsError(409, CallGate.BusyMessage);
}
// ------------------------------------------------------------ helpers
public static string DisplayName(CrmUser u) =>

View File

@@ -48,7 +48,7 @@ export function BrowserCall(props: { session: string; onSessionLost: (msg: strin
const { session, onSessionLost } = props;
const box = useRef<HTMLDivElement>(null);
const restart = useRef<() => void>(() => {});
const [phase, setPhaseState] = useState<"loading" | "in" | "ended" | "retry" | "error">("loading");
const [phase, setPhaseState] = useState<"loading" | "in" | "ended" | "retry" | "error" | "busy">("loading");
const [msg, setMsg] = useState("");
useEffect(() => {
@@ -56,7 +56,7 @@ export function BrowserCall(props: { session: string; onSessionLost: (msg: strin
let timer: number | undefined;
let fails = 0;
let alive = true;
const setPhase = (p: "loading" | "in" | "ended" | "retry" | "error") => { diag.phase = p; if (alive) setPhaseState(p); };
const setPhase = (p: "loading" | "in" | "ended" | "retry" | "error" | "busy") => { diag.phase = p; if (alive) setPhaseState(p); };
function stop() {
window.clearTimeout(timer);
@@ -79,7 +79,10 @@ export function BrowserCall(props: { session: string; onSessionLost: (msg: strin
let pass: Pass;
try { pass = await api.pass(session); }
catch (e) {
if ((e as { status?: number }).status === 401) { onSessionLost((e as Error).message); return; }
const status = (e as { status?: number }).status;
if (status === 401) { onSessionLost((e as Error).message); return; }
// 409 — уже идёт другой звонок (MAX_CONCURRENT_CALLS): сказать и ждать, пока человек не попробует снова.
if (status === 409) { setMsg((e as Error).message); setPhase("busy"); return; }
retryLater((e as Error).message); return;
}
try { await loadExternalApi(pass.domain); }
@@ -96,6 +99,8 @@ export function BrowserCall(props: { session: string; onSessionLost: (msg: strin
lang: "uk",
configOverwrite: {
disableDeepLinking: true, // страницу «скачайте приложение» заменяет модуль
startAudioOnly: true, // только голос (то же задано в config.js сервера)
startWithVideoMuted: true,
prejoinConfig: { enabled: diag.starts === 1 }, // при автоматическом повторном входе — без экрана подготовки
disableInviteFunctions: true,
},
@@ -137,6 +142,10 @@ export function BrowserCall(props: { session: string; onSessionLost: (msg: strin
<button className="btn ghost" onClick={() => window.close()}>Закрити вікно</button>
</div>
</div>}
{phase === "busy" && <div className="pcard">
<div className="t">{msg}</div>
<div className="row wrap mt-14"><button className="btn gold" onClick={() => restart.current()}><Icon name="refresh" />Спробувати ще раз</button></div>
</div>}
{phase === "error" && <div className="pcard">
<div className="t">Не вдалося підключитися: {msg}</div>
<div className="row wrap mt-14"><button className="btn gold" onClick={() => restart.current()}><Icon name="refresh" />Спробувати ще раз</button></div>

View File

@@ -1,6 +1,9 @@
# Модуль «Дзвінки» для CRM: https://crm-calls.volcanic.tes.gd (отдельное приложение Coolify).
# Jitsi — docker-compose.yml (другое приложение, https://meet.volcanic.tes.gd).
# Секреты JWT_APP_SECRET (тот же, что у Jitsi) и GUEST_LINK_SECRET — только в env Coolify.
# Секреты JWT_APP_SECRET (тот же, что у Jitsi), GUEST_LINK_SECRET, JIBRI_RECORDER_PASSWORD (тот же, что у Jitsi),
# OPENAI_API_KEY, CRM_USER_PASSWORD — только в env Coolify.
# Запись: модуль следит за комнатами через prosody и управляет Jibri по сети crm-calls-bridge (создаёт приложение Jitsi),
# файлы записей — в общем каталоге хоста RECORDINGS_HOST_DIR.
services:
calls:
build:
@@ -25,8 +28,28 @@ services:
Jitsi__Audience: '${JWT_AUDIENCE:-jitsi}'
Jitsi__AppSecret: '${JWT_APP_SECRET}'
Jitsi__TokenTtlMinutes: '${JITSI_TOKEN_TTL_MINUTES:-15}'
# --- запись и транскрипция
RECORDING_ENABLED: '${RECORDING_ENABLED:-true}'
MAX_CONCURRENT_CALLS: '${MAX_CONCURRENT_CALLS:-1}'
MIN_CALL_SECONDS: '${MIN_CALL_SECONDS:-60}'
RECORD_MIN_PARTICIPANTS: '${RECORD_MIN_PARTICIPANTS:-2}'
PROSODY_URL: 'http://jitsi-prosody:5280'
JIBRI_URL: 'http://jitsi-jibri:2222'
JIBRI_RECORDER_PASSWORD: '${JIBRI_RECORDER_PASSWORD}'
RECORDINGS_DIR: /recordings
OPENAI_API_KEY: '${OPENAI_API_KEY:-}'
OPENAI_TRANSCRIBE_MODEL: '${OPENAI_TRANSCRIBE_MODEL:-whisper-1}'
CRM_USER_EMAIL: '${CRM_USER_EMAIL:-}'
CRM_USER_PASSWORD: '${CRM_USER_PASSWORD:-}'
RECORDINGS_BUCKET: '${RECORDINGS_BUCKET:-files}'
RECORDINGS_PREFIX: '${RECORDINGS_PREFIX:-calls/}'
WIKI_URL: '${WIKI_URL:-https://wiki-crm.tes.gd}'
WIKI_CALLS_FOLDER: '${WIKI_CALLS_FOLDER:-Дзвінки}'
volumes:
- calls-data:/data
- '${RECORDINGS_HOST_DIR:-/data/crm-calls/recordings}:/recordings'
networks:
- calls-bridge
healthcheck:
test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"]
interval: 30s
@@ -34,5 +57,10 @@ services:
retries: 3
start_period: 20s
networks:
calls-bridge:
external: true
name: crm-calls-bridge
volumes:
calls-data: