Модуль: запись и транскрипция (монитор звонков, Jibri HTTP API, лимит MAX_CONCURRENT_CALLS, Whisper, Supabase Storage, страница в вики, повторы), тесты
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
403
calls/backend/Calls.Api.Tests/RecordingTests.cs
Normal file
403
calls/backend/Calls.Api.Tests/RecordingTests.cs
Normal 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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}" });
|
||||
});
|
||||
|
||||
76
calls/backend/Calls.Api/Recording/CallGate.cs
Normal file
76
calls/backend/Calls.Api/Recording/CallGate.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
132
calls/backend/Calls.Api/Recording/CallMonitor.cs
Normal file
132
calls/backend/Calls.Api/Recording/CallMonitor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
110
calls/backend/Calls.Api/Recording/CallsRepo.cs
Normal file
110
calls/backend/Calls.Api/Recording/CallsRepo.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
33
calls/backend/Calls.Api/Recording/HostStats.cs
Normal file
33
calls/backend/Calls.Api/Recording/HostStats.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
85
calls/backend/Calls.Api/Recording/JitsiClients.cs
Normal file
85
calls/backend/Calls.Api/Recording/JitsiClients.cs
Normal 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=…&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/<sessionId>.
|
||||
/// </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; }
|
||||
}
|
||||
}
|
||||
206
calls/backend/Calls.Api/Recording/Publishing.cs
Normal file
206
calls/backend/Calls.Api/Recording/Publishing.cs
Normal 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} с";
|
||||
}
|
||||
}
|
||||
79
calls/backend/Calls.Api/Recording/RecordingOptions.cs
Normal file
79
calls/backend/Calls.Api/Recording/RecordingOptions.cs
Normal 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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
152
calls/backend/Calls.Api/Recording/RecordingProcessor.cs
Normal file
152
calls/backend/Calls.Api/Recording/RecordingProcessor.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Calls.Api.Recording;
|
||||
|
||||
/// <summary>
|
||||
/// Обработка законченных записей (каталог RECORDINGS_DIR/<сессия> от 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);
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user