| 1 | #nullable enable |
| 2 | |
| 3 | namespace CascadeIDE.Features.Chat; |
| 4 | |
| 5 | /// <summary>Обработчики <c>kind=intercom</c> (поле <c>intercom_handler</c> в intent-catalog).</summary> |
| 6 | public static class ChatSlashIntercomHandlers |
| 7 | { |
| 8 | public static class Ids |
| 9 | { |
| 10 | public const string TopicOpen = "topic_open"; |
| 11 | public const string TopicCards = "topic_cards"; |
| 12 | public const string SpineOpen = "spine_open"; |
| 13 | public const string TopicList = "topic_list"; |
| 14 | public const string TopicTree = "topic_tree"; |
| 15 | public const string TopicCreate = "topic_create"; |
| 16 | public const string TopicRename = "topic_rename"; |
| 17 | public const string AttachSelection = "attach_selection"; |
| 18 | public const string AttachScope = "attach_scope"; |
| 19 | public const string AttachFile = "attach_file"; |
| 20 | public const string MessageSelect = "message_select"; |
| 21 | public const string MessageSelectClear = "message_select_clear"; |
| 22 | public const string MessageFind = "message_find"; |
| 23 | public const string MessageRelate = "message_relate"; |
| 24 | public const string MessageAnchorsList = "message_anchors_list"; |
| 25 | public const string AnchorPeek = "anchor_peek"; |
| 26 | public const string ServerStatus = "server_status"; |
| 27 | public const string ServerStart = "server_start"; |
| 28 | public const string ServerStop = "server_stop"; |
| 29 | public const string TeamMembers = "team_members"; |
| 30 | public const string TeamInvite = "team_invite"; |
| 31 | public const string TeamSeedProject = "team_seed_project"; |
| 32 | public const string AgentList = "agent_list"; |
| 33 | public const string AgentProvision = "agent_provision"; |
| 34 | public const string AgentSelect = "agent_select"; |
| 35 | } |
| 36 | |
| 37 | public sealed record Context( |
| 38 | string? ArgsTail, |
| 39 | Guid SelectedThreadId, |
| 40 | Action<Guid> SelectThread, |
| 41 | Action<bool> SetOverviewMode, |
| 42 | ChatSurfaceSnapshot Snapshot, |
| 43 | Action<TopicPickerPresentation>? SetTopicPicker, |
| 44 | Func<string, TopicCreateResult>? CreateTopicWithTitle, |
| 45 | Func<Guid, string, TopicRenameResult>? RenameTopicWithTitle, |
| 46 | Func<string, string?, ChatSlashIntercomResult>? TryAttachSlash, |
| 47 | Func<int, int, string>? SelectMessageByOrdinalRangeInDetailLane = null, |
| 48 | Func<IReadOnlyList<ParametricIntRange>, string>? SelectMessagesByOrdinalRangesInDetailLane = null, |
| 49 | Func<string>? ClearMessageSelectionInDetailLane = null, |
| 50 | Func<string?, string>? FindMessagesForCodeRef = null, |
| 51 | Func<string?, string>? RelateMessageRangeToCodeRef = null, |
| 52 | Func<string>? ListMessageAnchors = null, |
| 53 | Func<string?, string>? PeekAnchorById = null, |
| 54 | Func<string, string?, CancellationToken, Task<ChatSlashIntercomResult>>? RunAdmin = null); |
| 55 | |
| 56 | private delegate ChatSlashIntercomResult Handler(Context context); |
| 57 | |
| 58 | private static readonly IReadOnlyDictionary<string, Handler> Handlers = |
| 59 | new Dictionary<string, Handler>(StringComparer.OrdinalIgnoreCase) |
| 60 | { |
| 61 | [Ids.TopicOpen] = static ctx => ChatSlashIntercomActions.OpenTopic( |
| 62 | ctx.ArgsTail, |
| 63 | ctx.SelectedThreadId, |
| 64 | ctx.SelectThread, |
| 65 | ctx.SetOverviewMode, |
| 66 | ctx.Snapshot), |
| 67 | [Ids.TopicCards] = static ctx => ChatSlashIntercomActions.OpenTopicCards( |
| 68 | ctx.SetOverviewMode, |
| 69 | ctx.Snapshot), |
| 70 | [Ids.SpineOpen] = static ctx => ChatSlashIntercomActions.OpenTopicCards( |
| 71 | ctx.SetOverviewMode, |
| 72 | ctx.Snapshot), |
| 73 | [Ids.TopicList] = static ctx => ChatSlashIntercomActions.ShowTopicPicker( |
| 74 | TopicPickerPresentation.List, |
| 75 | ctx.SetTopicPicker, |
| 76 | ctx.SetOverviewMode, |
| 77 | ctx.Snapshot), |
| 78 | [Ids.TopicTree] = static ctx => ChatSlashIntercomActions.ShowTopicPicker( |
| 79 | TopicPickerPresentation.Tree, |
| 80 | ctx.SetTopicPicker, |
| 81 | ctx.SetOverviewMode, |
| 82 | ctx.Snapshot), |
| 83 | [Ids.TopicCreate] = static ctx => ChatSlashIntercomActions.CreateTopic( |
| 84 | ctx.ArgsTail, |
| 85 | ctx.CreateTopicWithTitle), |
| 86 | [Ids.TopicRename] = static ctx => ChatSlashIntercomActions.RenameTopic( |
| 87 | ctx.SelectedThreadId, |
| 88 | ctx.ArgsTail, |
| 89 | ctx.RenameTopicWithTitle), |
| 90 | [Ids.AttachSelection] = static ctx => executeAttach(ctx, Ids.AttachSelection), |
| 91 | [Ids.AttachScope] = static ctx => executeAttach(ctx, Ids.AttachScope), |
| 92 | [Ids.AttachFile] = static ctx => executeAttach(ctx, Ids.AttachFile), |
| 93 | [Ids.MessageSelect] = static ctx => executeMessageSelect(ctx), |
| 94 | [Ids.MessageSelectClear] = static ctx => executeMessageSelectClear(ctx), |
| 95 | [Ids.MessageFind] = static ctx => executeMessageFind(ctx), |
| 96 | [Ids.MessageRelate] = static ctx => executeMessageRelate(ctx), |
| 97 | [Ids.MessageAnchorsList] = static ctx => executeMessageAnchorsList(ctx), |
| 98 | [Ids.AnchorPeek] = static ctx => executeAnchorPeek(ctx), |
| 99 | [Ids.ServerStatus] = static ctx => executeAdmin(ctx, Ids.ServerStatus), |
| 100 | [Ids.ServerStart] = static ctx => executeAdmin(ctx, Ids.ServerStart), |
| 101 | [Ids.ServerStop] = static ctx => executeAdmin(ctx, Ids.ServerStop), |
| 102 | [Ids.TeamMembers] = static ctx => executeAdmin(ctx, Ids.TeamMembers), |
| 103 | [Ids.TeamInvite] = static ctx => executeAdmin(ctx, Ids.TeamInvite), |
| 104 | [Ids.TeamSeedProject] = static ctx => executeAdmin(ctx, Ids.TeamSeedProject), |
| 105 | [Ids.AgentList] = static ctx => executeAdmin(ctx, Ids.AgentList), |
| 106 | [Ids.AgentProvision] = static ctx => executeAdmin(ctx, Ids.AgentProvision), |
| 107 | [Ids.AgentSelect] = static ctx => executeAdmin(ctx, Ids.AgentSelect), |
| 108 | }; |
| 109 | |
| 110 | private static ChatSlashIntercomResult executeAttach(Context ctx, string handlerId) |
| 111 | { |
| 112 | if (ctx.TryAttachSlash is null) |
| 113 | return ChatSlashIntercomResult.Fail("Attach недоступен в этой сессии."); |
| 114 | return ctx.TryAttachSlash(handlerId, ctx.ArgsTail); |
| 115 | } |
| 116 | |
| 117 | private static ChatSlashIntercomResult executeMessageSelect(Context ctx) |
| 118 | { |
| 119 | var tail = ctx.ArgsTail?.Trim() ?? ""; |
| 120 | if (!ParametricSegmentListParser.TryParse(tail, out var segments, out var parseError)) |
| 121 | return ChatSlashIntercomResult.Fail(parseError); |
| 122 | |
| 123 | if (segments.Count > 1) |
| 124 | { |
| 125 | if (ctx.SelectMessagesByOrdinalRangesInDetailLane is null) |
| 126 | return ChatSlashIntercomResult.Fail("Выбор сообщений (multi-range) недоступен."); |
| 127 | |
| 128 | var multiResult = ctx.SelectMessagesByOrdinalRangesInDetailLane(segments); |
| 129 | if (!string.Equals(multiResult, "OK", StringComparison.Ordinal)) |
| 130 | return ChatSlashIntercomResult.Fail(multiResult); |
| 131 | |
| 132 | var labels = segments.Select(static s => |
| 133 | s.Start == s.End ? $"#{s.Start}" : $"#{s.Start}–#{s.End}"); |
| 134 | return ChatSlashIntercomResult.Ok($"Выбрано: {string.Join(", ", labels)}."); |
| 135 | } |
| 136 | |
| 137 | if (ctx.SelectMessageByOrdinalRangeInDetailLane is null) |
| 138 | return ChatSlashIntercomResult.Fail("Выбор сообщения недоступен."); |
| 139 | |
| 140 | var range = segments[0]; |
| 141 | var result = ctx.SelectMessageByOrdinalRangeInDetailLane(range.Start, range.End); |
| 142 | if (!string.Equals(result, "OK", StringComparison.Ordinal)) |
| 143 | return ChatSlashIntercomResult.Fail(result); |
| 144 | |
| 145 | var text = range.Start == range.End |
| 146 | ? $"Выбрано сообщение #{range.Start}." |
| 147 | : $"Выбран диапазон #{range.Start}–#{range.End} (активно #{range.End})."; |
| 148 | return ChatSlashIntercomResult.Ok(text); |
| 149 | } |
| 150 | |
| 151 | private static ChatSlashIntercomResult executeMessageSelectClear(Context ctx) |
| 152 | { |
| 153 | if (ctx.ClearMessageSelectionInDetailLane is null) |
| 154 | return ChatSlashIntercomResult.Fail("Сброс выбора сообщений недоступен."); |
| 155 | |
| 156 | var tail = ctx.ArgsTail?.Trim() ?? ""; |
| 157 | if (tail.Length > 0) |
| 158 | return ChatSlashIntercomResult.Fail("Ожидается «/intercom message select clear» без аргументов."); |
| 159 | |
| 160 | var result = ctx.ClearMessageSelectionInDetailLane(); |
| 161 | return string.Equals(result, "OK", StringComparison.Ordinal) |
| 162 | ? ChatSlashIntercomResult.Ok("Подсветка сообщений в ветке сброшена.") |
| 163 | : ChatSlashIntercomResult.Fail(result); |
| 164 | } |
| 165 | |
| 166 | private static ChatSlashIntercomResult executeMessageFind(Context ctx) |
| 167 | { |
| 168 | if (ctx.FindMessagesForCodeRef is null) |
| 169 | return ChatSlashIntercomResult.Fail("Поиск сообщений по коду недоступен."); |
| 170 | |
| 171 | var result = ctx.FindMessagesForCodeRef(ctx.ArgsTail); |
| 172 | return result.StartsWith("Связанные сообщения:", StringComparison.Ordinal) |
| 173 | ? ChatSlashIntercomResult.Ok(result) |
| 174 | : ChatSlashIntercomResult.Fail(result); |
| 175 | } |
| 176 | |
| 177 | private static ChatSlashIntercomResult executeMessageRelate(Context ctx) |
| 178 | { |
| 179 | if (ctx.RelateMessageRangeToCodeRef is null) |
| 180 | return ChatSlashIntercomResult.Fail("Relate недоступен."); |
| 181 | |
| 182 | var result = ctx.RelateMessageRangeToCodeRef(ctx.ArgsTail); |
| 183 | return result.StartsWith("Связь сообщений", StringComparison.Ordinal) |
| 184 | ? ChatSlashIntercomResult.Ok(result) |
| 185 | : ChatSlashIntercomResult.Fail(result); |
| 186 | } |
| 187 | |
| 188 | private static ChatSlashIntercomResult executeMessageAnchorsList(Context ctx) |
| 189 | { |
| 190 | if (ctx.ListMessageAnchors is null) |
| 191 | return ChatSlashIntercomResult.Fail("Список якорей недоступен."); |
| 192 | |
| 193 | return ChatSlashIntercomResult.Ok(ctx.ListMessageAnchors()); |
| 194 | } |
| 195 | |
| 196 | private static ChatSlashIntercomResult executeAdmin(Context ctx, string handlerId) |
| 197 | { |
| 198 | if (ctx.RunAdmin is null) |
| 199 | return ChatSlashIntercomResult.Fail("Intercom admin недоступен в этой сессии."); |
| 200 | |
| 201 | return ctx.RunAdmin(handlerId, ctx.ArgsTail, CancellationToken.None) |
| 202 | .ConfigureAwait(false) |
| 203 | .GetAwaiter() |
| 204 | .GetResult(); |
| 205 | } |
| 206 | |
| 207 | private static ChatSlashIntercomResult executeAnchorPeek(Context ctx) |
| 208 | { |
| 209 | if (ctx.PeekAnchorById is null) |
| 210 | return ChatSlashIntercomResult.Fail("Peek якоря недоступен."); |
| 211 | |
| 212 | var result = ctx.PeekAnchorById(ctx.ArgsTail); |
| 213 | if (result.Contains("не найден", StringComparison.OrdinalIgnoreCase) |
| 214 | || result.Contains("Укажи id", StringComparison.OrdinalIgnoreCase) |
| 215 | || result.Contains("8 hex", StringComparison.OrdinalIgnoreCase)) |
| 216 | { |
| 217 | return ChatSlashIntercomResult.Fail(result); |
| 218 | } |
| 219 | |
| 220 | return ChatSlashIntercomResult.Ok(result); |
| 221 | } |
| 222 | |
| 223 | public static bool IsKnown(string handlerId) => Handlers.ContainsKey(handlerId); |
| 224 | |
| 225 | public static bool TryExecute(string handlerId, Context context, out ChatSlashIntercomResult result) |
| 226 | { |
| 227 | if (Handlers.TryGetValue(handlerId, out var handler)) |
| 228 | { |
| 229 | result = handler(context); |
| 230 | return true; |
| 231 | } |
| 232 | |
| 233 | result = ChatSlashIntercomResult.Fail(""); |
| 234 | return false; |
| 235 | } |
| 236 | } |
| 237 | |