using System.IO; using System.Net.Http; using System.Runtime.InteropServices; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using AxCopilot.SDK; using AxCopilot.Services; using AxCopilot.Themes; namespace AxCopilot.Handlers; /// /// .skill.json 파일을 읽어 IActionHandler로 변환하는 로더. /// JSON 스킬은 외부 API를 호출하는 동적 핸들러를 코드 없이 정의합니다. /// public static class JsonSkillLoader { public static IActionHandler? Load(string filePath) { var json = File.ReadAllText(filePath); var def = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); if (def == null) return null; return new JsonSkillHandler(def); } } public class JsonSkillDefinition { public string Id { get; set; } = ""; public string Name { get; set; } = ""; public string Version { get; set; } = "1.0"; public string Prefix { get; set; } = ""; public JsonSkillCredential? Credential { get; set; } public JsonSkillRequest? Request { get; set; } public JsonSkillResponse? Response { get; set; } public JsonSkillCache? Cache { get; set; } } public class JsonSkillCredential { public string Type { get; set; } = "bearer_token"; // bearer_token | basic_auth public string CredentialKey { get; set; } = ""; } public class JsonSkillRequest { public string Method { get; set; } = "GET"; public string Url { get; set; } = ""; public Dictionary? Headers { get; set; } public object? Body { get; set; } } public class JsonSkillResponse { public string ResultsPath { get; set; } = "results"; public string TitleField { get; set; } = "title"; public string? SubtitleField { get; set; } public string? ActionUrl { get; set; } } public class JsonSkillCache { public int Ttl { get; set; } = 0; // 초 단위 } /// /// JSON 스킬 정의를 기반으로 실제 HTTP 호출을 수행하는 핸들러 /// public class JsonSkillHandler : IActionHandler { private readonly JsonSkillDefinition _def; private readonly HttpClient _http = new(); private List? _cache; private DateTime _cacheExpiry = DateTime.MinValue; public string? Prefix => _def.Prefix; public PluginMetadata Metadata => new(_def.Id, _def.Name, _def.Version, "JSON Skill"); public JsonSkillHandler(JsonSkillDefinition def) { _def = def; _http.Timeout = TimeSpan.FromSeconds(3); // 인증 헤더 설정 if (def.Credential?.Type == "bearer_token") { var token = CredentialManager.GetToken(def.Credential.CredentialKey); if (!string.IsNullOrEmpty(token)) _http.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token); } } public async Task> GetItemsAsync(string query, CancellationToken ct) { // 캐시 확인 if (_cache != null && DateTime.Now < _cacheExpiry) return _cache; if (_def.Request == null || _def.Response == null) return Enumerable.Empty(); try { var url = _def.Request.Url.Replace("{{INPUT}}", Uri.EscapeDataString(query)); // URL 유효성 검증: http/https 스킴만 허용 if (!Uri.TryCreate(url, UriKind.Absolute, out var parsedUrl) || (parsedUrl.Scheme != Uri.UriSchemeHttp && parsedUrl.Scheme != Uri.UriSchemeHttps)) { LogService.Error($"[{_def.Name}] 유효하지 않은 URL: {url}"); return [new LauncherItem("설정 오류", "스킬 URL이 유효하지 않습니다 (http/https만 허용)", null, null)]; } var response = _def.Request.Method.ToUpper() switch { "POST" => await _http.PostAsync(url, BuildBody(query), ct), _ => await _http.GetAsync(url, ct) }; response.EnsureSuccessStatusCode(); var json = await response.Content.ReadAsStringAsync(ct); var items = ParseResults(json); // 캐시 저장 if (_def.Cache?.Ttl > 0) { _cache = items; _cacheExpiry = DateTime.Now.AddSeconds(_def.Cache.Ttl); } return items; } catch (TaskCanceledException) { // 타임아웃 → 캐시 반환 if (_cache != null) { LogService.Warn($"[{_def.Name}] API 타임아웃, 캐시 반환"); return _cache; } return [new LauncherItem("네트워크 오류", "연결을 확인하세요", null, null)]; } catch (Exception ex) { LogService.Error($"[{_def.Name}] API 호출 실패: {ex.Message}"); return [new LauncherItem($"오류: {ex.Message}", _def.Name, null, null)]; } } public Task ExecuteAsync(LauncherItem item, CancellationToken ct) { if (item.ActionUrl != null && Uri.TryCreate(item.ActionUrl, UriKind.Absolute, out var uri) && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) { System.Diagnostics.Process.Start( new System.Diagnostics.ProcessStartInfo(item.ActionUrl) { UseShellExecute = true }); } return Task.CompletedTask; } private HttpContent BuildBody(string query) { var bodyJson = JsonSerializer.Serialize(_def.Request?.Body) .Replace("\"{{INPUT}}\"", $"\"{query}\""); return new StringContent(bodyJson, Encoding.UTF8, "application/json"); } private List ParseResults(string json) { var items = new List(); try { var root = JsonNode.Parse(json); if (root == null) return items; // resultsPath로 배열 탐색 (dot notation) var node = NavigatePath(root, _def.Response!.ResultsPath); if (node is not JsonArray arr) return items; foreach (var element in arr.Take(10)) { if (element == null) continue; var title = NavigatePath(element, _def.Response.TitleField)?.ToString() ?? "(제목 없음)"; var subtitle = _def.Response.SubtitleField != null ? NavigatePath(element, _def.Response.SubtitleField)?.ToString() ?? "" : ""; var actionUrl = _def.Response.ActionUrl != null ? NavigatePath(element, _def.Response.ActionUrl)?.ToString() : null; items.Add(new LauncherItem(title, subtitle, null, element, actionUrl, Symbols.Cloud)); } } catch (Exception ex) { LogService.Error($"[{_def.Name}] 응답 파싱 실패: {ex.Message}"); } return items; } private static JsonNode? NavigatePath(JsonNode root, string path) { var parts = path.Split('.'); JsonNode? current = root; foreach (var part in parts) { if (current == null) return null; // 배열 인덱스 처리: field[0] var bracketIdx = part.IndexOf('['); if (bracketIdx >= 0) { var closingIdx = part.IndexOf(']'); if (closingIdx < 0) return null; // 잘못된 경로 형식 (예: field[0 ) var fieldName = part[..bracketIdx]; var index = int.Parse(part[(bracketIdx + 1)..closingIdx]); current = current[fieldName]?[index]; } else { current = current[part]; } } return current; } } /// /// Windows Credential Manager (advapi32.dll)를 사용해 자격증명을 안전하게 저장/조회합니다. /// DPAPI 기반 암호화로 현재 사용자 계정에 귀속되어 저장됩니다. /// public static class CredentialManager { private const uint CRED_TYPE_GENERIC = 1; private const uint CRED_PERSIST_LOCAL_MACHINE = 2; [StructLayout(LayoutKind.Sequential)] private struct FILETIME { public uint dwLowDateTime; public uint dwHighDateTime; } [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] private struct CREDENTIAL { public uint Flags; public uint Type; public IntPtr TargetName; public IntPtr Comment; public FILETIME LastWritten; public uint CredentialBlobSize; public IntPtr CredentialBlob; public uint Persist; public uint AttributeCount; public IntPtr Attributes; public IntPtr TargetAlias; public IntPtr UserName; } [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CredRead(string target, uint type, uint flags, out IntPtr credential); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CredWrite([In] ref CREDENTIAL userCredential, uint flags); [DllImport("advapi32.dll", SetLastError = true)] private static extern void CredFree(IntPtr cred); [DllImport("advapi32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern bool CredDelete(string target, uint type, uint flags); /// /// Windows Credential Manager에서 토큰을 읽습니다. /// 저장된 자격증명이 없으면 환경변수에서 폴백합니다. /// public static string? GetToken(string key) { if (string.IsNullOrEmpty(key)) return null; try { if (CredRead(key, CRED_TYPE_GENERIC, 0, out IntPtr ptr)) { try { var cred = Marshal.PtrToStructure(ptr); if (cred.CredentialBlobSize > 0 && cred.CredentialBlob != IntPtr.Zero) return Marshal.PtrToStringUni(cred.CredentialBlob, (int)cred.CredentialBlobSize / 2); } finally { CredFree(ptr); } } } catch (Exception ex) { LogService.Warn($"Windows Credential Manager 읽기 실패 ({key}): {ex.Message}"); } // 환경변수 폴백 (개발 환경용) return Environment.GetEnvironmentVariable(key.ToUpperInvariant()); } /// /// Windows Credential Manager에 토큰을 DPAPI로 암호화하여 저장합니다. /// public static void SetToken(string key, string token) { if (string.IsNullOrEmpty(key) || string.IsNullOrEmpty(token)) return; var blob = Encoding.Unicode.GetBytes(token); var blobPtr = Marshal.AllocHGlobal(blob.Length); var targetPtr = Marshal.StringToCoTaskMemUni(key); var userPtr = Marshal.StringToCoTaskMemUni(Environment.UserName); try { Marshal.Copy(blob, 0, blobPtr, blob.Length); var cred = new CREDENTIAL { Type = CRED_TYPE_GENERIC, TargetName = targetPtr, UserName = userPtr, CredentialBlobSize = (uint)blob.Length, CredentialBlob = blobPtr, Persist = CRED_PERSIST_LOCAL_MACHINE, }; if (!CredWrite(ref cred, 0)) LogService.Error($"토큰 저장 실패: {key}, 오류 코드: {Marshal.GetLastWin32Error()}"); else LogService.Info($"토큰 저장 완료: {key}"); } finally { Marshal.FreeHGlobal(blobPtr); Marshal.FreeCoTaskMem(targetPtr); Marshal.FreeCoTaskMem(userPtr); } } /// /// Windows Credential Manager에서 자격증명을 삭제합니다. /// public static bool DeleteToken(string key) => !string.IsNullOrEmpty(key) && CredDelete(key, CRED_TYPE_GENERIC, 0); }