317 lines
14 KiB
C#
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);
|
|
}
|
|
}
|