using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; namespace Lskj.AgentPet.Host.Core.Configuration; public sealed class HostConfiguration { private static readonly Regex SessionId = new( "^[A-Za-z0-9_.:-]{8,128}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private static readonly Regex ProcessBoundSessionId = new( "^lserp-pet-p(?[1-9][0-9]{0,9})-s(?[0-9]{9,12})-c(?[a-f0-9]{32})-[A-Fa-f0-9]{32}$", RegexOptions.Compiled | RegexOptions.CultureInvariant); private HostConfiguration() { } public required Uri AstrBotBaseUri { get; init; } public required string AstrBotApiKey { get; init; } public required string AstrBotSessionId { get; init; } public required string BridgeClientSessionId { get; init; } public int? BridgeProcessId { get; init; } public long? BridgeProcessStartedAtUnixSeconds { get; init; } public required ErpSessionScopeBinding ExpectedSessionScope { get; init; } public required string SpritePath { get; init; } public required string BridgeDiscoveryDirectory { get; init; } public TimeSpan BridgeTimeout { get; init; } = TimeSpan.FromMinutes(3); public TimeSpan ChatTimeout { get; init; } = TimeSpan.FromMinutes(5); public TimeSpan AttachmentUploadTimeout { get; init; } = TimeSpan.FromMinutes(2); public int MaximumAttachmentCount { get; init; } = 3; public long MaximumAttachmentFileBytes { get; init; } = 12 * 1024 * 1024; public long MaximumAttachmentTotalBytes { get; init; } = 36 * 1024 * 1024; public static HostConfiguration Load( IReadOnlyDictionary environment, string applicationDirectory) { ArgumentNullException.ThrowIfNull(environment); if (string.IsNullOrWhiteSpace(applicationDirectory)) throw new HostError("application_directory_missing", "桌宠程序目录无效。"); string baseUrl = Value(environment, "LSERP_ASTRBOT_BASE_URL") ?? "http://127.0.0.1:6185"; if (!Uri.TryCreate(baseUrl, UriKind.Absolute, out Uri? baseUri) || (baseUri.Scheme != Uri.UriSchemeHttps && baseUri.Scheme != Uri.UriSchemeHttp)) { throw new HostError("astrbot_url_invalid", "AstrBot 地址必须是有效的 HTTP/HTTPS 地址。"); } if (!baseUri.IsLoopback) { throw new HostError( "astrbot_loopback_required", "当前桌宠只支持与 ERP 同机、同一 Windows 用户边界内的 AstrBot;远程模式必须使用尚未启用的 Agent Gateway。"); } if (!string.IsNullOrEmpty(baseUri.UserInfo) || !string.IsNullOrEmpty(baseUri.Query) || !string.IsNullOrEmpty(baseUri.Fragment)) { throw new HostError( "astrbot_url_invalid", "AstrBot 地址不得包含用户信息、查询参数或片段。"); } string apiKey = Value(environment, "LSERP_ASTRBOT_API_KEY") ?? string.Empty; if (apiKey.Length < 16 || apiKey.Length > 512) throw new HostError("astrbot_api_key_missing", "未配置有效的 AstrBot chat + file scope API Key。"); string? configuredSessionId = Value( environment, "LSERP_ASTRBOT_SESSION_ID"); if (configuredSessionId is null) { throw new HostError( "astrbot_session_process_binding_required", "必须由商用启动器传入绑定 ERP PID 和启动时间的 AstrBot 会话。"); } string sessionId = configuredSessionId; if (!SessionId.IsMatch(sessionId)) throw new HostError("astrbot_session_invalid", "AstrBot 会话 ID 格式无效。"); if (!ProcessBoundSessionId.IsMatch(sessionId)) { throw new HostError( "astrbot_session_process_binding_required", "AstrBot 会话必须精确绑定 ERP PID、进程启动时间和随机会话值。"); } int? sessionProcessId = ProcessIdFromSession(sessionId); int? configuredProcessId = OptionalProcessId(environment, "LSERP_AGENT_BRIDGE_PROCESS_ID"); if (!configuredProcessId.HasValue) { throw new HostError( "bridge_process_id_required", "必须由商用启动器显式传入目标 ERP 进程 ID。"); } if (!sessionProcessId.HasValue || sessionProcessId.Value != configuredProcessId.Value) { throw new HostError( "bridge_process_id_mismatch", "AstrBot 会话绑定的 ERP 进程与显式配置不一致。"); } ErpSessionScopeBinding expectedScope = ErpSessionScopeBinding.Create( RequiredValue(environment, "LSERP_AGENT_EXPECTED_DATABASE_SCOPE_FINGERPRINT"), RequiredValue(environment, "LSERP_AGENT_EXPECTED_USER_ID"), RequiredValue(environment, "LSERP_AGENT_EXPECTED_USER_NAME"), RequiredValue(environment, "LSERP_AGENT_EXPECTED_ACCOUNT_BOOK"), RequiredValue(environment, "LSERP_AGENT_EXPECTED_SUBSYSTEM_ID"), RequiredBoolean( environment, "LSERP_AGENT_EXPECTED_IS_ADMINISTRATOR")); string configuredScopeToken = ErpSessionScopeBinding.ValidateToken( RequiredValue(environment, "LSERP_AGENT_EXPECTED_SESSION_SCOPE_TOKEN")); string? sessionScopeToken = SessionScopeTokenFromSession(sessionId); if (sessionScopeToken is null || !ErpSessionScopeBinding.TokenEquals(expectedScope.Token, configuredScopeToken) || !ErpSessionScopeBinding.TokenEquals(expectedScope.Token, sessionScopeToken)) { throw new HostError( "bridge_session_scope_token_mismatch", "AstrBot 会话绑定的 ERP 用户身份、账套、子系统或数据库与显式配置不一致。"); } string sprite = FullPath( Value(environment, "LSERP_PET_SPRITE_PATH") ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".codex", "pets", "guga", "spritesheet.webp"), "pet_sprite_path_invalid"); string discovery = FullPath( Value(environment, "LSERP_AGENT_BRIDGE_DISCOVERY") ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Langsu", "Lserp", "AgentBridge"), "bridge_discovery_path_invalid"); int maximumCount = Integer(environment, "LSERP_ATTACHMENT_MAX_COUNT", 3, 1, 3); int maximumFileMb = Integer(environment, "LSERP_ATTACHMENT_MAX_FILE_MB", 12, 1, 12); int maximumTotalMb = Integer( environment, "LSERP_ATTACHMENT_MAX_TOTAL_MB", maximumCount * maximumFileMb, maximumFileMb, maximumCount * maximumFileMb); return new HostConfiguration { AstrBotBaseUri = EnsureTrailingSlash(baseUri), AstrBotApiKey = apiKey, AstrBotSessionId = sessionId, BridgeClientSessionId = CreateBridgeClientSessionId(sessionId), BridgeProcessId = configuredProcessId, BridgeProcessStartedAtUnixSeconds = ProcessStartedAtFromSession(sessionId), ExpectedSessionScope = expectedScope, SpritePath = sprite, BridgeDiscoveryDirectory = discovery, BridgeTimeout = Milliseconds(environment, "LSERP_AGENT_BRIDGE_TIMEOUT_MS", 180_000, 1_000, 300_000), ChatTimeout = Milliseconds(environment, "LSERP_ASTRBOT_TIMEOUT_MS", 300_000, 5_000, 600_000), AttachmentUploadTimeout = Milliseconds(environment, "LSERP_ATTACHMENT_UPLOAD_TIMEOUT_MS", 120_000, 5_000, 300_000), MaximumAttachmentCount = maximumCount, MaximumAttachmentFileBytes = maximumFileMb * 1024L * 1024L, MaximumAttachmentTotalBytes = maximumTotalMb * 1024L * 1024L }; } public static string CreateBridgeClientSessionId(string astrBotSessionId) { if (string.IsNullOrWhiteSpace(astrBotSessionId) || !SessionId.IsMatch(astrBotSessionId)) { throw new HostError("astrbot_session_invalid", "AstrBot 会话 ID 格式无效。"); } byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(astrBotSessionId)); return "astrbot-" + Convert.ToHexString(digest).ToLowerInvariant()[..32]; } public static int? ProcessIdFromSession(string astrBotSessionId) { if (string.IsNullOrWhiteSpace(astrBotSessionId) || !SessionId.IsMatch(astrBotSessionId)) { throw new HostError("astrbot_session_invalid", "AstrBot 会话 ID 格式无效。"); } Match match = ProcessBoundSessionId.Match(astrBotSessionId); if (!match.Success) return null; if (!int.TryParse(match.Groups["pid"].Value, out int processId) || processId <= 0) { throw new HostError("bridge_process_id_invalid", "ERP 进程 ID 格式无效。"); } return processId; } public static long? ProcessStartedAtFromSession(string astrBotSessionId) { if (string.IsNullOrWhiteSpace(astrBotSessionId) || !SessionId.IsMatch(astrBotSessionId)) { throw new HostError("astrbot_session_invalid", "AstrBot 会话 ID 格式无效。"); } Match match = ProcessBoundSessionId.Match(astrBotSessionId); if (!match.Success || !match.Groups["started"].Success) return null; if (!long.TryParse(match.Groups["started"].Value, out long startedAt) || startedAt <= 0 || startedAt > 253402300799L) { throw new HostError("bridge_process_start_invalid", "ERP 进程启动时间指纹无效。"); } return startedAt; } public static string? SessionScopeTokenFromSession(string astrBotSessionId) { if (string.IsNullOrWhiteSpace(astrBotSessionId) || !SessionId.IsMatch(astrBotSessionId)) { throw new HostError("astrbot_session_invalid", "AstrBot 会话 ID 格式无效。"); } Match match = ProcessBoundSessionId.Match(astrBotSessionId); if (!match.Success || !match.Groups["scope"].Success) return null; return ErpSessionScopeBinding.ValidateToken(match.Groups["scope"].Value); } public byte[] ValidateFiles() { return WebpAtlasValidator.ReadValidatedBytes(SpritePath); } private static string? Value(IReadOnlyDictionary values, string name) { return values.TryGetValue(name, out string? value) && !string.IsNullOrWhiteSpace(value) ? value.Trim() : null; } private static string RequiredValue( IReadOnlyDictionary values, string name) { return Value(values, name) ?? throw new HostError( "expected_erp_session_scope_required", "商用启动器必须传入完整的预期 ERP 会话作用域。"); } private static bool RequiredBoolean( IReadOnlyDictionary values, string name) { string value = RequiredValue(values, name); if (string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) return true; if (string.Equals(value, "false", StringComparison.OrdinalIgnoreCase)) return false; throw new HostError( "expected_erp_session_scope_invalid", "预期 ERP 管理员状态必须是 true 或 false。"); } private static string FullPath(string value, string errorCode) { try { return Path.GetFullPath(value); } catch (Exception error) when (error is ArgumentException or NotSupportedException or PathTooLongException) { throw new HostError(errorCode, "桌宠路径配置无效。", error); } } private static TimeSpan Milliseconds( IReadOnlyDictionary values, string name, int defaultValue, int minimum, int maximum) { string? raw = Value(values, name); if (raw is null) return TimeSpan.FromMilliseconds(defaultValue); if (!int.TryParse(raw, out int value) || value < minimum || value > maximum) throw new HostError("timeout_invalid", name + " 超出允许范围。"); return TimeSpan.FromMilliseconds(value); } private static int Integer( IReadOnlyDictionary values, string name, int defaultValue, int minimum, int maximum) { string? raw = Value(values, name); if (raw is null) return defaultValue; if (!int.TryParse(raw, out int value) || value < minimum || value > maximum) throw new HostError("attachment_limit_invalid", name + " 超出允许范围。"); return value; } private static int? OptionalProcessId( IReadOnlyDictionary values, string name) { string? raw = Value(values, name); if (raw is null) return null; if (!int.TryParse(raw, out int value) || value <= 0) throw new HostError("bridge_process_id_invalid", name + " 必须是有效的 ERP 进程 ID。"); return value; } private static Uri EnsureTrailingSlash(Uri value) { string text = value.AbsoluteUri.EndsWith("/", StringComparison.Ordinal) ? value.AbsoluteUri : value.AbsoluteUri + "/"; return new Uri(text, UriKind.Absolute); } }