351 lines
12 KiB
C#
351 lines
12 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// .skill.json 파일을 읽어 IActionHandler로 변환하는 로더.
|
|
/// JSON 스킬은 외부 API를 호출하는 동적 핸들러를 코드 없이 정의합니다.
|
|
/// </summary>
|
|
public static class JsonSkillLoader
|
|
{
|
|
public static IActionHandler? Load(string filePath)
|
|
{
|
|
var json = File.ReadAllText(filePath);
|
|
var def = JsonSerializer.Deserialize<JsonSkillDefinition>(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<string, string>? 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; // 초 단위
|
|
}
|
|
|
|
/// <summary>
|
|
/// JSON 스킬 정의를 기반으로 실제 HTTP 호출을 수행하는 핸들러
|
|
/// </summary>
|
|
public class JsonSkillHandler : IActionHandler
|
|
{
|
|
private readonly JsonSkillDefinition _def;
|
|
private readonly HttpClient _http = new();
|
|
private List<LauncherItem>? _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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
|
|
{
|
|
// 캐시 확인
|
|
if (_cache != null && DateTime.Now < _cacheExpiry)
|
|
return _cache;
|
|
|
|
if (_def.Request == null || _def.Response == null)
|
|
return Enumerable.Empty<LauncherItem>();
|
|
|
|
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<LauncherItem> ParseResults(string json)
|
|
{
|
|
var items = new List<LauncherItem>();
|
|
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;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Windows Credential Manager (advapi32.dll)를 사용해 자격증명을 안전하게 저장/조회합니다.
|
|
/// DPAPI 기반 암호화로 현재 사용자 계정에 귀속되어 저장됩니다.
|
|
/// </summary>
|
|
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);
|
|
|
|
/// <summary>
|
|
/// Windows Credential Manager에서 토큰을 읽습니다.
|
|
/// 저장된 자격증명이 없으면 환경변수에서 폴백합니다.
|
|
/// </summary>
|
|
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<CREDENTIAL>(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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Windows Credential Manager에 토큰을 DPAPI로 암호화하여 저장합니다.
|
|
/// </summary>
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Windows Credential Manager에서 자격증명을 삭제합니다.
|
|
/// </summary>
|
|
public static bool DeleteToken(string key) =>
|
|
!string.IsNullOrEmpty(key) && CredDelete(key, CRED_TYPE_GENERIC, 0);
|
|
}
|