Files
crm-calls/calls/backend/Calls.Api/Recording/Publishing.cs

207 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} с";
}
}