Files
lserp_cs_6.0/插件库/Lskj.AgentPet.Host.Core/Configuration/HostConfiguration.cs
T
2026-08-14 14:28:28 +08:00

317 lines
14 KiB
C#

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(?<pid>[1-9][0-9]{0,9})-s(?<started>[0-9]{9,12})-c(?<scope>[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<string, string?> 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<string, string?> values, string name)
{
return values.TryGetValue(name, out string? value) && !string.IsNullOrWhiteSpace(value)
? value.Trim()
: null;
}
private static string RequiredValue(
IReadOnlyDictionary<string, string?> values,
string name)
{
return Value(values, name) ?? throw new HostError(
"expected_erp_session_scope_required",
"商用启动器必须传入完整的预期 ERP 会话作用域。");
}
private static bool RequiredBoolean(
IReadOnlyDictionary<string, string?> 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<string, string?> 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<string, string?> 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<string, string?> 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);
}
}