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);
}