| 1 | using CascadeIDE.Features.Chat; |
| 2 | using CascadeIDE.Features.Intercom.Transport; |
| 3 | using CascadeIDE.Models; |
| 4 | using CascadeIDE.Services; |
| 5 | using CascadeIDE.Services.Intercom; |
| 6 | |
| 7 | namespace CascadeIDE.Features.Intercom.Admin; |
| 8 | |
| 9 | /// <summary>Team/server admin из CIDE (ADR 0147 фазы 1–4).</summary> |
| 10 | public sealed class IntercomAdminService |
| 11 | { |
| 12 | private readonly IntercomTransportCoordinator _transport; |
| 13 | private readonly IntercomServerHostService _serverHost = new(); |
| 14 | private readonly Func<CascadeIdeSettings> _getSettings; |
| 15 | private readonly Action _saveSettings; |
| 16 | |
| 17 | public IntercomAdminService( |
| 18 | IntercomTransportCoordinator transport, |
| 19 | Func<CascadeIdeSettings> getSettings, |
| 20 | Action saveSettings) |
| 21 | { |
| 22 | _transport = transport; |
| 23 | _getSettings = getSettings; |
| 24 | _saveSettings = saveSettings; |
| 25 | } |
| 26 | |
| 27 | public IntercomServerHostService ServerHost => _serverHost; |
| 28 | |
| 29 | public async Task<ChatSlashIntercomResult> ExecuteAsync( |
| 30 | string handlerId, |
| 31 | string? argsTail, |
| 32 | CancellationToken ct) |
| 33 | { |
| 34 | return handlerId switch |
| 35 | { |
| 36 | ChatSlashIntercomHandlers.Ids.ServerStatus => await ServerStatusAsync(ct).ConfigureAwait(false), |
| 37 | ChatSlashIntercomHandlers.Ids.ServerStart => ServerStart(argsTail), |
| 38 | ChatSlashIntercomHandlers.Ids.ServerStop => ServerStop(), |
| 39 | ChatSlashIntercomHandlers.Ids.TeamMembers => await TeamMembersAsync(ct).ConfigureAwait(false), |
| 40 | ChatSlashIntercomHandlers.Ids.TeamInvite => await TeamInviteAsync(argsTail, ct).ConfigureAwait(false), |
| 41 | ChatSlashIntercomHandlers.Ids.TeamSeedProject => await TeamSeedProjectAsync(argsTail, ct).ConfigureAwait(false), |
| 42 | ChatSlashIntercomHandlers.Ids.AgentList => await AgentListAsync(ct).ConfigureAwait(false), |
| 43 | ChatSlashIntercomHandlers.Ids.AgentProvision => await AgentProvisionAsync(argsTail, ct).ConfigureAwait(false), |
| 44 | ChatSlashIntercomHandlers.Ids.AgentSelect => AgentSelect(argsTail), |
| 45 | _ => ChatSlashIntercomResult.Fail($"Неизвестная admin-команда: {handlerId}"), |
| 46 | }; |
| 47 | } |
| 48 | |
| 49 | public async Task<(bool Ok, string Message)> RefreshWorkspaceContextAsync(CancellationToken ct = default) |
| 50 | { |
| 51 | var settings = _getSettings(); |
| 52 | var transport = settings.Intercom.Transport; |
| 53 | if (!transport.IsConfigured) |
| 54 | return (false, "Transport не настроен."); |
| 55 | |
| 56 | _transport.OnWorkspaceChanged(); |
| 57 | |
| 58 | if (!await _transport.EnsureBearerForAdminAsync(transport, ct).ConfigureAwait(false)) |
| 59 | return (false, "Нужен Connect или dev_team_token."); |
| 60 | |
| 61 | var bearer = await _transport.ResolveBearerForAdminAsync(transport, ct).ConfigureAwait(false); |
| 62 | var resolved = await IntercomWorkspaceContextResolver.ResolveAsync( |
| 63 | transport, |
| 64 | _transport.GetWorkspaceRootForAdmin(), |
| 65 | bearer, |
| 66 | _transport.ApiClient, |
| 67 | ct).ConfigureAwait(false); |
| 68 | |
| 69 | if (!resolved.Found) |
| 70 | return (false, $"team не найден (repo={resolved.RepoKey ?? "—"})."); |
| 71 | |
| 72 | if (string.IsNullOrWhiteSpace(transport.TeamId)) |
| 73 | transport.TeamId = resolved.TeamId; |
| 74 | |
| 75 | _saveSettings(); |
| 76 | return (true, $"workspace → team {resolved.TeamId} ({resolved.Source})."); |
| 77 | } |
| 78 | |
| 79 | private async Task<ChatSlashIntercomResult> ServerStatusAsync(CancellationToken ct) |
| 80 | { |
| 81 | var settings = _getSettings().Intercom.Transport; |
| 82 | var host = _serverHost.DescribeStatus(); |
| 83 | var baseUrl = settings.ResolveBaseUrl(); |
| 84 | if (string.IsNullOrWhiteSpace(baseUrl)) |
| 85 | return ChatSlashIntercomResult.Ok($"{host}\nbase_url не задан."); |
| 86 | |
| 87 | _transport.ApiClient.ConfigureBaseUrl(baseUrl); |
| 88 | var ping = await _transport.ApiClient.PingHealthAsync(ct).ConfigureAwait(false); |
| 89 | var health = ping ? "HTTP /health OK" : "HTTP /health недоступен"; |
| 90 | |
| 91 | if (!await _transport.EnsureBearerForAdminAsync(settings, ct).ConfigureAwait(false)) |
| 92 | return ChatSlashIntercomResult.Ok($"{host}\n{baseUrl}: {health}. Bearer: нет."); |
| 93 | |
| 94 | var bearer = await _transport.ResolveBearerForAdminAsync(settings, ct).ConfigureAwait(false); |
| 95 | var teamId = settings.TeamId.Trim(); |
| 96 | if (string.IsNullOrWhiteSpace(teamId)) |
| 97 | return ChatSlashIntercomResult.Ok($"{host}\n{baseUrl}: {health}. team_id не задан."); |
| 98 | |
| 99 | var admin = await _transport.ApiClient.GetTeamAdminHealthAsync(teamId, bearer!, ct).ConfigureAwait(false); |
| 100 | var adminText = admin is null |
| 101 | ? "admin/health: нет доступа" |
| 102 | : $"admin/health: {admin.Status}, SSE={admin.SseSubscribers}"; |
| 103 | return ChatSlashIntercomResult.Ok($"{host}\n{baseUrl}: {health}. {adminText}. Transport: {_transport.ConnectionStatus}"); |
| 104 | } |
| 105 | |
| 106 | private ChatSlashIntercomResult ServerStart(string? argsTail) |
| 107 | { |
| 108 | var settings = _getSettings().Intercom.Transport; |
| 109 | var url = string.IsNullOrWhiteSpace(argsTail) ? settings.ResolveBaseUrl() : argsTail.Trim(); |
| 110 | if (string.IsNullOrWhiteSpace(url)) |
| 111 | url = _serverHost.DefaultBaseUrl; |
| 112 | |
| 113 | var (ok, message) = _serverHost.Start(url, settings.ResolveLocalServerPath()); |
| 114 | if (ok && string.IsNullOrWhiteSpace(settings.BaseUrl)) |
| 115 | { |
| 116 | settings.BaseUrl = url; |
| 117 | settings.Enabled = true; |
| 118 | _saveSettings(); |
| 119 | } |
| 120 | |
| 121 | return ok ? ChatSlashIntercomResult.Ok(message) : ChatSlashIntercomResult.Fail(message); |
| 122 | } |
| 123 | |
| 124 | private ChatSlashIntercomResult ServerStop() |
| 125 | { |
| 126 | var (ok, message) = _serverHost.Stop(); |
| 127 | return ok ? ChatSlashIntercomResult.Ok(message) : ChatSlashIntercomResult.Fail(message); |
| 128 | } |
| 129 | |
| 130 | private async Task<ChatSlashIntercomResult> TeamMembersAsync(CancellationToken ct) |
| 131 | { |
| 132 | var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false); |
| 133 | if (error is not null) |
| 134 | return ChatSlashIntercomResult.Fail(error); |
| 135 | |
| 136 | var members = await _transport.ApiClient.ListTeamMembersAsync(teamId!, bearer!, ct).ConfigureAwait(false); |
| 137 | if (members.Count == 0) |
| 138 | return ChatSlashIntercomResult.Ok("Участников нет."); |
| 139 | |
| 140 | var lines = members.Select(m => |
| 141 | $"{m.MemberId} · {m.MemberKind}/{m.TeamRole} · {m.DisplayName}"); |
| 142 | return ChatSlashIntercomResult.Ok(string.Join(Environment.NewLine, lines)); |
| 143 | } |
| 144 | |
| 145 | private async Task<ChatSlashIntercomResult> TeamInviteAsync(string? argsTail, CancellationToken ct) |
| 146 | { |
| 147 | var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false); |
| 148 | if (error is not null) |
| 149 | return ChatSlashIntercomResult.Fail(error); |
| 150 | |
| 151 | var role = string.IsNullOrWhiteSpace(argsTail) ? "member" : argsTail.Trim(); |
| 152 | var invite = await _transport.ApiClient.CreateInviteAsync(teamId!, role, bearer!, ct).ConfigureAwait(false); |
| 153 | if (invite is null) |
| 154 | return ChatSlashIntercomResult.Fail("Не удалось создать invite (нужна роль admin)."); |
| 155 | |
| 156 | return ChatSlashIntercomResult.Ok( |
| 157 | $"invite {invite.InviteId}: role={invite.TeamRole}, token={invite.Token}, expires={invite.ExpiresAtUtc}"); |
| 158 | } |
| 159 | |
| 160 | private async Task<ChatSlashIntercomResult> TeamSeedProjectAsync(string? argsTail, CancellationToken ct) |
| 161 | { |
| 162 | var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false); |
| 163 | if (error is not null) |
| 164 | return ChatSlashIntercomResult.Fail(error); |
| 165 | |
| 166 | var repoRoot = _transport.GetWorkspaceRootForAdmin(); |
| 167 | var repoUrl = IntercomWorkspaceGitRemoteResolver.TryGetNormalizedOrigin(repoRoot); |
| 168 | if (string.IsNullOrWhiteSpace(repoUrl)) |
| 169 | return ChatSlashIntercomResult.Fail("Не удалось нормализовать origin репозитория."); |
| 170 | |
| 171 | var projectId = string.IsNullOrWhiteSpace(argsTail) |
| 172 | ? SanitizeProjectId(teamId!) |
| 173 | : argsTail.Trim(); |
| 174 | |
| 175 | var created = await _transport.ApiClient.CreateProjectAsync(projectId, projectId, bearer!, ct) |
| 176 | .ConfigureAwait(false); |
| 177 | if (created is null) |
| 178 | return ChatSlashIntercomResult.Fail("POST /projects не удался."); |
| 179 | |
| 180 | if (!await _transport.ApiClient.PutProjectReposAsync(projectId, [repoUrl], bearer!, ct).ConfigureAwait(false)) |
| 181 | return ChatSlashIntercomResult.Fail("PUT project repos не удался."); |
| 182 | |
| 183 | if (!await _transport.ApiClient.PutTeamProjectsAsync(teamId!, [projectId], bearer!, ct).ConfigureAwait(false)) |
| 184 | return ChatSlashIntercomResult.Fail("PUT team projects не удался."); |
| 185 | |
| 186 | _ = await RefreshWorkspaceContextAsync(ct).ConfigureAwait(false); |
| 187 | return ChatSlashIntercomResult.Ok($"project {projectId} ← {repoUrl}, team {teamId} привязан."); |
| 188 | } |
| 189 | |
| 190 | private async Task<ChatSlashIntercomResult> AgentListAsync(CancellationToken ct) |
| 191 | { |
| 192 | var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false); |
| 193 | if (error is not null) |
| 194 | return ChatSlashIntercomResult.Fail(error); |
| 195 | |
| 196 | var members = await _transport.ApiClient.ListTeamMembersAsync(teamId!, bearer!, ct).ConfigureAwait(false); |
| 197 | var agents = members.Where(m => string.Equals(m.MemberKind, "agent", StringComparison.Ordinal)).ToList(); |
| 198 | if (agents.Count == 0) |
| 199 | return ChatSlashIntercomResult.Ok("Агентов нет. /intercom agent provision <имя>"); |
| 200 | |
| 201 | var selected = _getSettings().Intercom.Transport.SelectedAgentMemberId; |
| 202 | var lines = agents.Select(a => |
| 203 | { |
| 204 | var mark = string.Equals(a.MemberId, selected, StringComparison.Ordinal) ? " *" : ""; |
| 205 | return $"{a.MemberId}{mark} · {a.DisplayName}"; |
| 206 | }); |
| 207 | return ChatSlashIntercomResult.Ok(string.Join(Environment.NewLine, lines)); |
| 208 | } |
| 209 | |
| 210 | private async Task<ChatSlashIntercomResult> AgentProvisionAsync(string? argsTail, CancellationToken ct) |
| 211 | { |
| 212 | var name = argsTail?.Trim() ?? ""; |
| 213 | if (string.IsNullOrWhiteSpace(name)) |
| 214 | return ChatSlashIntercomResult.Fail("Укажи имя: /intercom agent provision <display name>"); |
| 215 | |
| 216 | var (bearer, teamId, error) = await RequireTeamBearerAsync(ct).ConfigureAwait(false); |
| 217 | if (error is not null) |
| 218 | return ChatSlashIntercomResult.Fail(error); |
| 219 | |
| 220 | var agent = await _transport.ApiClient.ProvisionAgentAsync(teamId!, name, null, bearer!, ct) |
| 221 | .ConfigureAwait(false); |
| 222 | if (agent is null) |
| 223 | return ChatSlashIntercomResult.Fail("Provision не удался (нужна роль admin)."); |
| 224 | |
| 225 | var secrets = IntercomAgentSecretsStorage.Load(); |
| 226 | secrets.CredentialsByTeamAgent[IntercomAgentSecrets.MakeKey(teamId!, agent.MemberId)] = |
| 227 | agent.CredentialToken; |
| 228 | IntercomAgentSecretsStorage.Save(secrets); |
| 229 | |
| 230 | var transport = _getSettings().Intercom.Transport; |
| 231 | transport.SelectedAgentMemberId = agent.MemberId; |
| 232 | transport.SelectedAgentDisplayName = agent.DisplayName; |
| 233 | _saveSettings(); |
| 234 | |
| 235 | return ChatSlashIntercomResult.Ok( |
| 236 | $"Агент {agent.DisplayName} ({agent.MemberId}). Credential сохранён в LocalAppData (не в git)."); |
| 237 | } |
| 238 | |
| 239 | private ChatSlashIntercomResult AgentSelect(string? argsTail) |
| 240 | { |
| 241 | var memberId = argsTail?.Trim() ?? ""; |
| 242 | if (string.IsNullOrWhiteSpace(memberId)) |
| 243 | return ChatSlashIntercomResult.Fail("Укажи member_id: /intercom agent select <id>"); |
| 244 | |
| 245 | var transport = _getSettings().Intercom.Transport; |
| 246 | transport.SelectedAgentMemberId = memberId; |
| 247 | transport.SelectedAgentDisplayName = ""; |
| 248 | _saveSettings(); |
| 249 | return ChatSlashIntercomResult.Ok($"Выбран агент {memberId} для transport fan-out."); |
| 250 | } |
| 251 | |
| 252 | private async Task<(string? Bearer, string? TeamId, string? Error)> RequireTeamBearerAsync(CancellationToken ct) |
| 253 | { |
| 254 | var settings = _getSettings().Intercom.Transport; |
| 255 | if (!settings.IsConfigured) |
| 256 | return (null, null, "Transport не настроен (base_url)."); |
| 257 | |
| 258 | _transport.ApiClient.ConfigureBaseUrl(settings.ResolveBaseUrl()); |
| 259 | if (!await _transport.EnsureBearerForAdminAsync(settings, ct).ConfigureAwait(false)) |
| 260 | return (null, null, "Нужен Connect или dev_team_token."); |
| 261 | |
| 262 | var teamId = settings.TeamId.Trim(); |
| 263 | if (string.IsNullOrWhiteSpace(teamId)) |
| 264 | return (null, null, "team_id не задан."); |
| 265 | |
| 266 | var bearer = await _transport.ResolveBearerForAdminAsync(settings, ct).ConfigureAwait(false); |
| 267 | return string.IsNullOrWhiteSpace(bearer) |
| 268 | ? (null, null, "Bearer недоступен.") |
| 269 | : (bearer, teamId, null); |
| 270 | } |
| 271 | |
| 272 | private static string SanitizeProjectId(string teamId) => |
| 273 | new string(teamId.Where(c => char.IsLetterOrDigit(c) || c is '-' or '_').ToArray()); |
| 274 | } |
| 275 | |