diff --git a/HuaJiBot.NET.slnx b/HuaJiBot.NET.slnx index 1f45f95..bf2b0a0 100644 --- a/HuaJiBot.NET.slnx +++ b/HuaJiBot.NET.slnx @@ -1,5 +1,6 @@ + diff --git a/README.md b/README.md index 1fd5335..59f242d 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,9 @@ you guess it. (> ω <) │ └── ... (Other core files) ├── 🗂️ HuaJiBot.NET.Adapter.* (Adapters for connecting to IM platforms) │ ├── OneBot (Adapter for the OneBot protocol) -│ └── Satori (Adapter for the Satori protocol) -│ └── ... (Other potential adapters) +│ ├── Satori (Adapter for the Satori protocol) +│ ├── Kook (Adapter for KOOK using Kook.Net) +│ └── ... (Other potential adapters) ├── 🗂️ HuaJiBot.NET.Plugin.* (Plugins for specific functionalities) │ ├── Calendar (Plugin for pushing calendar events) │ ├── GitHubBridge (Plugin for pushing GitHub events) diff --git a/src/HuaJiBot.NET.Adapter.Kook/Channels/KookChannelId.cs b/src/HuaJiBot.NET.Adapter.Kook/Channels/KookChannelId.cs new file mode 100644 index 0000000..14d6938 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Channels/KookChannelId.cs @@ -0,0 +1,53 @@ +using System.Diagnostics.CodeAnalysis; + +namespace HuaJiBot.NET.Adapter.Kook.Channels; + +/// +/// 结构化的 KOOK 频道标识:由构造保证"私聊 / 群聊"在类型上显式可辨, +/// 取代散落各处的 ulong / Guid 解析推断。 +/// +internal readonly record struct KookChannelId +{ + private KookChannelId(KookChannelKind kind, ulong guildChannelId) + { + Kind = kind; + GuildChannelId = guildChannelId; + } + + private KookChannelId(KookChannelKind kind, Guid directChatCode) + { + Kind = kind; + DirectChatCode = directChatCode; + } + + public KookChannelKind Kind { get; } + + public ulong GuildChannelId { get; } + + public Guid DirectChatCode { get; } + + public static KookChannelId Guild(ulong channelId) => + new(KookChannelKind.Guild, channelId); + + public static KookChannelId Direct(Guid chatCode) => + new(KookChannelKind.Direct, chatCode); + + /// + /// 服务器文字频道 ID 为十进制 ulong,私聊 chatCode 为 Guid。 + /// + public static bool TryParse([NotNullWhen(true)] string? raw, out KookChannelId id) + { + if (ulong.TryParse(raw, out var channelId)) + { + id = Guild(channelId); + return true; + } + if (Guid.TryParse(raw, out var chatCode)) + { + id = Direct(chatCode); + return true; + } + id = default; + return false; + } +} diff --git a/src/HuaJiBot.NET.Adapter.Kook/Channels/KookChannelKind.cs b/src/HuaJiBot.NET.Adapter.Kook/Channels/KookChannelKind.cs new file mode 100644 index 0000000..73e1a07 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Channels/KookChannelKind.cs @@ -0,0 +1,14 @@ +namespace HuaJiBot.NET.Adapter.Kook.Channels; + +internal enum KookChannelKind +{ + /// + /// 服务器文字频道,标识为 ulong。 + /// + Guild, + + /// + /// 私聊频道,标识为 Guid(chatCode)。 + /// + Direct, +} diff --git a/src/HuaJiBot.NET.Adapter.Kook/HuaJiBot.NET.Adapter.Kook.csproj b/src/HuaJiBot.NET.Adapter.Kook/HuaJiBot.NET.Adapter.Kook.csproj new file mode 100644 index 0000000..00e54a0 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/HuaJiBot.NET.Adapter.Kook.csproj @@ -0,0 +1,14 @@ + + + net10.0 + enable + enable + + + + + + + + + diff --git a/src/HuaJiBot.NET.Adapter.Kook/KookAdapter.cs b/src/HuaJiBot.NET.Adapter.Kook/KookAdapter.cs new file mode 100644 index 0000000..8c1129d --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/KookAdapter.cs @@ -0,0 +1,389 @@ +using HuaJiBot.NET.Adapter.Kook.Channels; +using HuaJiBot.NET.Adapter.Kook.Messaging; +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Events; +using HuaJiBot.NET.Logger; +using Kook; +using Kook.WebSocket; + +namespace HuaJiBot.NET.Adapter.Kook; + +/// +/// KOOK 协议适配器。 +/// +/// +/// 对接 KOOK 开放平台的 Bot。 +/// +public class KookAdapter : BotServiceBase +{ + private readonly KookSocketClient _client; + private readonly string _token; + + /// + public override required ILogger Logger { get; init; } + + /// + /// 使用指定的 Bot 令牌创建 KOOK 适配器。 + /// + /// KOOK 机器人令牌。 + /// + /// 可选的 KOOK 客户端配置。为 时使用适配器默认配置。 + /// + public KookAdapter(string token, KookSocketConfig? config = null) + { + _token = token; + _client = new KookSocketClient( + config + ?? new KookSocketConfig + { + // KOOK 不允许同一 Bot 多处在线,默认情况下登录前先退出旧连接以确保能收到网关事件。 + AutoLogoutBeforeLogin = true, + } + ); + + _client.Log += OnClientLog; + _client.Ready += OnReadyAsync; + _client.MessageReceived += OnMessageReceivedAsync; + _client.DirectMessageReceived += OnDirectMessageReceivedAsync; + } + + #region 生命周期 + + protected override void ReconnectCore() => _ = ReconnectAsync(); + + protected override Task SetupServiceAsyncCore() => ConnectAsync(); + + private async Task ConnectAsync() + { + await _client.LoginAsync(TokenType.Bot, _token); + await _client.StartAsync(); + } + + private async Task ReconnectAsync() + { + try + { + await _client.StopAsync(); + await ConnectAsync(); + } + catch (Exception e) + { + LogError("KOOK 重新连接失败", e); + } + } + + private Task OnClientLog(LogMessage msg) + { + var text = $"[KOOK] {msg.Source}: {msg.Message}"; + switch (msg.Severity) + { + case LogSeverity.Critical or LogSeverity.Error: + LogError(text, msg.Exception); + break; + case LogSeverity.Warning: + Warn(text); + break; + case LogSeverity.Debug or LogSeverity.Verbose: + LogDebug(text); + break; + default: + Log(text); + break; + } + return Task.CompletedTask; + } + + private Task OnReadyAsync() + { + var self = _client.CurrentUser; + Events.CallOnBotLogin( + new BotLoginEventArgs + { + Service = this, + Accounts = self is null ? [] : [self.Id.ToString()], + ClientName = "KOOK", + ClientVersion = self?.Username, + } + ); + return Task.CompletedTask; + } + + /// + public override string[] AllRobots => + _client.CurrentUser is { } self ? [self.Id.ToString()] : []; + + #endregion + + #region 接收消息 + + private Task OnMessageReceivedAsync( + SocketMessage message, + SocketGuildUser author, + SocketTextChannel channel + ) + { + try + { + var self = _client.CurrentUser; + + if (self is not null && author.Id == self.Id) + return Task.CompletedTask; + + if (message is not SocketUserMessage userMessage) + return Task.CompletedTask; + + Events.CallOnGroupMessageReceived( + new GroupMessageEventArgs( + () => new KookCommandReader(userMessage), + () => new ValueTask(channel.Guild.Name + "#" + channel.Name) + ) + { + Service = this, + RobotId = self?.Id.ToString(), + MessageId = message.Id.ToString(), + GroupId = channel.Id.ToString(), + SenderId = author.Id.ToString(), + SenderMemberCard = author.DisplayName, + SenderMemberType = ResolveMemberType(author), + TextMessageLazy = new(() => KookCommandReader.ToReadableText(userMessage)), + } + ); + } + catch (Exception e) + { + LogError("处理 KOOK 消息失败", e); + } + return Task.CompletedTask; + } + + private Task OnDirectMessageReceivedAsync( + SocketMessage message, + SocketUser author, + SocketDMChannel channel + ) + { + try + { + var self = _client.CurrentUser; + + if (self is not null && author.Id == self.Id) + return Task.CompletedTask; + + if (message is not SocketUserMessage userMessage) + return Task.CompletedTask; + + Events.CallOnPrivateMessageReceived( + new PrivateMessageEventArgs(() => new KookCommandReader(userMessage)) + { + Service = this, + RobotId = self?.Id.ToString(), + MessageId = message.Id.ToString(), + GroupId = channel.ChatCode.ToString(), + SenderId = author.Id.ToString(), + TextMessageLazy = new(() => KookCommandReader.ToReadableText(userMessage)), + } + ); + } + catch (Exception e) + { + LogError("处理 KOOK 私聊消息失败", e); + } + return Task.CompletedTask; + } + + private static MemberType ResolveMemberType(SocketGuildUser user) + { + if (user.IsOwner == true || user.Guild.OwnerId == user.Id) + return MemberType.Owner; + if (user.GuildPermissions.Administrator) + return MemberType.Admin; + return MemberType.Member; + } + + private async Task ResolveMessageChannelAsync(KookChannelId channelId) => + channelId.Kind switch + { + KookChannelKind.Guild => _client.GetChannel(channelId.GuildChannelId) as IMessageChannel, + KookChannelKind.Direct => await _client.GetDMChannelAsync(channelId.DirectChatCode), + _ => throw new ArgumentOutOfRangeException( + nameof(channelId), + channelId.Kind, + "未知的 KOOK 频道种类" + ), + }; + + private async Task ResolveMessageChannelAsync(string targetGroup) => + KookChannelId.TryParse(targetGroup, out var channelId) + ? await ResolveMessageChannelAsync(channelId) + : null; + + #endregion + + #region 发送 / 撤回 + + /// + public override async Task SendGroupMessageAsync( + string? robotId, + string targetGroup, + params SendingMessageBase[] messages + ) + { + messages = await messages.ExpandPostsAsync(); + + if (await ResolveMessageChannelAsync(targetGroup) is not { } channel) + throw new InvalidOperationException($"未找到 KOOK 频道 {targetGroup}"); + + var messageIds = new List(); + foreach (var segment in KookOutboundBuilder.Build(messages)) + { + switch (segment) + { + case KookTextSegment { Markdown: var markdown, Quote: var quote }: + var text = await channel.SendTextAsync(markdown, quote); + messageIds.Add(text.Id.ToString()); + break; + + case KookImageSegment { ImagePath: var path, Quote: var quote }: + using (var attachment = KookOutboundBuilder.CreateImageAttachment(path)) + { + var image = await channel.SendFileAsync(attachment, quote); + messageIds.Add(image.Id.ToString()); + } + break; + } + } + + return messageIds.ToArray(); + } + + /// + /// + /// KOOK 原生支持 KMarkdown。此实现将 作为 KMarkdown 原样发送, + /// 使加粗、链接、提及、表情等富文本得以正确渲染;因此内容不会被转义,调用方需自行保证 KMarkdown 合法。 + /// + public override async Task SendRichMessageAsync( + string? robotId, + string targetGroup, + RichContent content, + Func> fallback + ) + { + if (await ResolveMessageChannelAsync(targetGroup) is not { } channel) + throw new InvalidOperationException($"未找到 KOOK 频道 {targetGroup}"); + + IQuote? quote = + content.ReplyToMessageId is { } replyTo && Guid.TryParse(replyTo, out var quotedId) + ? new MessageReference(quotedId) + : null; + + var result = await channel.SendTextAsync(content.Markdown, quote); + return [result.Id.ToString()]; + } + + /// + public override void RecallMessage(string? robotId, string targetGroup, string msgId) + { + if (!Guid.TryParse(msgId, out var messageId)) + { + LogError($"撤回消息失败,非法消息 ID:{msgId}", null); + return; + } + LogFailure(RecallAsync(), $"撤回消息 {msgId} 失败"); + return; + + async Task RecallAsync() + { + if (await ResolveMessageChannelAsync(targetGroup) is not { } channel) + { + LogError($"撤回消息失败,未找到频道:{targetGroup}", null); + return; + } + await channel.DeleteMessageAsync(messageId); + } + } + + private async void LogFailure(Task task, string message) + { + try + { + await task; + } + catch (Exception e) + { + LogError(message, e); + } + } + + #endregion + + #region 群(频道)管理 + + protected override void SetGroupNameCore(string? robotId, string targetGroup, string groupName) + { + if (!KookChannelId.TryParse(targetGroup, out var channelId)) + throw new ArgumentException($"非法的 KOOK 频道标识:{targetGroup}", nameof(targetGroup)); + + switch (channelId.Kind) + { + case KookChannelKind.Guild: + if (_client.GetChannel(channelId.GuildChannelId) is not ITextChannel channel) + { + LogError($"修改频道名失败,未找到文字频道:{targetGroup}", null); + return; + } + LogFailure( + channel.ModifyAsync(p => p.Name = groupName), + $"修改频道 {targetGroup} 名称失败" + ); + break; + + case KookChannelKind.Direct: + throw new NotSupportedException("KOOK 私聊频道不支持修改名称"); + + default: + throw new ArgumentOutOfRangeException( + nameof(targetGroup), + channelId.Kind, + "未知的 KOOK 频道种类" + ); + } + } + + protected override MemberType GetMemberTypeCore(string robotId, string targetGroup, string userId) + { + if (!ulong.TryParse(userId, out var uid) || !KookChannelId.TryParse(targetGroup, out var channelId)) + return MemberType.Unknown; + + switch (channelId.Kind) + { + case KookChannelKind.Guild: + if (_client.GetChannel(channelId.GuildChannelId) is not SocketTextChannel channel) + return MemberType.Unknown; + var guild = channel.Guild; + // 群主判定不依赖用户缓存:OwnerId 始终可用;管理员判定需用户已缓存,否则不臆造。 + if (guild.OwnerId == uid) + return MemberType.Owner; + return guild.GetUser(uid) is { } user ? ResolveMemberType(user) : MemberType.Unknown; + + case KookChannelKind.Direct: + // 私聊没有成员类型概念。 + return MemberType.Unknown; + + default: + throw new ArgumentOutOfRangeException( + nameof(targetGroup), + channelId.Kind, + "未知的 KOOK 频道种类" + ); + } + } + + protected override string GetNickCore(string robotId, string userId) + { + if (ulong.TryParse(userId, out var uid) && _client.GetUser(uid) is { } user) + return user.Username; + return userId; + } + + #endregion +} diff --git a/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookCommandReader.cs b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookCommandReader.cs new file mode 100644 index 0000000..61a85e1 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookCommandReader.cs @@ -0,0 +1,127 @@ +using HuaJiBot.NET.Commands; +using Kook; +using Kook.WebSocket; + +namespace HuaJiBot.NET.Adapter.Kook.Messaging; + +/// +/// 将 KOOK 网关消息按结构拆分为文本 / At / 回复 / 图片(URL) 序列,保留原始顺序。 +/// 图片以 URL 作为文本片段参与命令解析(框架命令实体不含图片类型)。 +/// +internal class KookCommandReader(SocketUserMessage message) : CommonCommandReader +{ + public override IEnumerable Msg => Parse(message); + + /// + /// 供适配器生成 TextMessage:面向人的可读展示,与命令解析(纯 URL / 结构化)分离。 + /// + public static string ToReadableText(SocketUserMessage message) => + KookMessageRenderer.ToReadableText(message); + + private static IEnumerable Parse(SocketUserMessage message) + { + if (message.Quote is { QuotedMessageId: var quotedId } && quotedId != Guid.Empty) + { + var quoteDetail = message.Quote as Quote; + yield return new ReaderReply( + new( + messageId: quotedId.ToString(), + senderId: quoteDetail?.Author?.Id.ToString(), + content: quoteDetail?.Content + ) + ); + } + + if (message.MaybeTextImageMixedMessage()) + { + foreach (var entity in ParseMixedCard(message)) + yield return entity; + yield break; + } + + foreach (var entity in ParseContentWithMentions(message.Content ?? string.Empty, message)) + yield return entity; + + // KOOK 附件位于正文之后。 + foreach (var attachment in message.Attachments) + { + if (attachment.Type is AttachmentType.Image && !string.IsNullOrEmpty(attachment.Url)) + yield return new ReaderText(attachment.Url); + } + } + + private static IEnumerable ParseContentWithMentions( + string content, + SocketUserMessage message + ) + { + var userMentionTags = message + .Tags.Where(x => x.Type is TagType.UserMention) + .OrderBy(x => x.Index) + .ToArray(); + + var cursor = 0; + foreach (var tag in userMentionTags) + { + if (tag.Index < cursor || tag.Index > content.Length) + continue; + + if (tag.Index > cursor) + { + var text = content[cursor..tag.Index]; + if (!string.IsNullOrEmpty(text)) + yield return new ReaderText(text); + } + + var (atTarget, atName) = tag.Value switch + { + SocketGuildUser guildUser => (guildUser.Id.ToString(), guildUser.DisplayName), + IUser user => (user.Id.ToString(), user.Username), + _ when tag.Key is ulong id => (id.ToString(), (string?)null), + _ => (null, null), + }; + if (atTarget is not null) + yield return new ReaderAt(atTarget, atName is null ? null : "@" + atName); + + cursor = Math.Min(tag.Index + tag.Length, content.Length); + } + + if (cursor < content.Length) + { + var text = content[cursor..]; + if (!string.IsNullOrEmpty(text)) + yield return new ReaderText(text); + } + } + + private static IEnumerable ParseMixedCard(SocketUserMessage message) + { + foreach (var module in message.Cards.OfType().SelectMany(x => x.Modules)) + { + switch (module) + { + case SectionModule { Text: KMarkdownElement { Content: var content } }: + foreach (var entity in ParseContentWithMentions(content, message)) + yield return entity; + break; + case SectionModule { Text: PlainTextElement { Content: var content } }: + if (!string.IsNullOrEmpty(content)) + yield return new ReaderText(content); + break; + case ContainerModule { Elements: var elements }: + foreach (var element in elements) + if (!string.IsNullOrEmpty(element.Source)) + yield return new ReaderText(element.Source); + break; + case ImageGroupModule { Elements: var elements }: + foreach (var element in elements) + if (!string.IsNullOrEmpty(element.Source)) + yield return new ReaderText(element.Source); + break; + case IMediaModule { Source: { Length: > 0 } source }: + yield return new ReaderText(source); + break; + } + } + } +} diff --git a/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookImageSegment.cs b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookImageSegment.cs new file mode 100644 index 0000000..4dd7cdc --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookImageSegment.cs @@ -0,0 +1,9 @@ +using Kook; + +namespace HuaJiBot.NET.Adapter.Kook.Messaging; + +/// +/// 单张图片附件。 +/// +internal sealed record KookImageSegment(string ImagePath, IQuote? Quote) + : KookOutboundSegment(Quote); diff --git a/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookMessageRenderer.cs b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookMessageRenderer.cs new file mode 100644 index 0000000..d0edba4 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookMessageRenderer.cs @@ -0,0 +1,189 @@ +using Kook; +using Kook.WebSocket; + +namespace HuaJiBot.NET.Adapter.Kook.Messaging; + +/// +/// 将 KOOK 消息渲染为面向人的可读文本,与命令解析(结构化 reader)职责分离。 +/// 卡片按 Card -> Module -> Element 渲染为树形;媒体消息列出附件基本信息。 +/// +internal static class KookMessageRenderer +{ + public static string ToReadableText(SocketUserMessage message) + { + if (message.Type is MessageType.Card || message.MaybeTextImageMixedMessage()) + return DescribeCards(message); + + if (message.Attachments.Count > 0) + return string.Join(" ", message.Attachments.Select(DescribeAttachment)); + + return message.Resolve(); + } + + private static string DescribeAttachment(IAttachment attachment) + { + var type = attachment.Type switch + { + AttachmentType.Image => "图片", + AttachmentType.Video => "视频", + AttachmentType.Audio => "音频", + AttachmentType.File => "文件", + _ => "附件", + }; + var name = string.IsNullOrEmpty(attachment.Filename) ? null : attachment.Filename; + return name is null ? $"[{type}] {attachment.Url}" : $"[{type}] {name} {attachment.Url}"; + } + + /// + /// 将卡片消息渲染为 Card -> Module -> Element 的树形结构。 + /// + private static string DescribeCards(SocketUserMessage message) + { + var lines = new List(); + foreach (var card in message.Cards.OfType()) + { + lines.Add("[卡片]"); + AppendModules(card.Modules, string.Empty, lines); + } + return string.Join("\n", lines); + } + + private static void AppendModules( + IReadOnlyList modules, + string prefix, + List lines + ) + { + for (var i = 0; i < modules.Count; i++) + { + var last = i == modules.Count - 1; + DescribeModule(modules[i], prefix, last, lines); + } + } + + private static void DescribeModule( + IModule module, + string prefix, + bool last, + List lines + ) + { + var self = prefix + Branch(last); + var childPrefix = prefix + Indent(last); + switch (module) + { + case HeaderModule { Text.Content: var content }: + lines.Add($"{self}[标题] {content}"); + break; + case SectionModule { Text: var text, Accessory: var accessory }: + lines.Add($"{self}[内容]"); + var children = new List(); + if (text is not null) + children.Add(text); + if (accessory is not null) + children.Add(accessory); + AppendElements(children, childPrefix, lines); + break; + case ContainerModule { Elements: var elements }: + lines.Add($"{self}[容器]"); + AppendElements(elements.Cast().ToArray(), childPrefix, lines); + break; + case ImageGroupModule { Elements: var elements }: + lines.Add($"{self}[图片组]"); + AppendElements(elements.Cast().ToArray(), childPrefix, lines); + break; + case ActionGroupModule { Elements: var elements }: + lines.Add($"{self}[交互]"); + AppendElements(elements.Cast().ToArray(), childPrefix, lines); + break; + case ContextModule { Elements: var elements }: + lines.Add($"{self}[备注]"); + AppendElements(elements, childPrefix, lines); + break; + case FileModule { Source: var source, Title: var title }: + lines.Add($"{self}{Media("文件", title, source)}"); + break; + case VideoModule { Source: var source, Title: var title }: + lines.Add($"{self}{Media("视频", title, source)}"); + break; + case AudioModule { Source: var source, Title: var title }: + lines.Add($"{self}{Media("音频", title, source)}"); + break; + case CountdownModule: + lines.Add($"{self}[倒计时]"); + break; + case InviteModule: + lines.Add($"{self}[邀请]"); + break; + case DividerModule: + lines.Add($"{self}[分割线]"); + break; + default: + lines.Add($"{self}[{module.Type}]"); + break; + } + } + + private static void AppendElements( + IReadOnlyList elements, + string prefix, + List lines + ) + { + for (var i = 0; i < elements.Count; i++) + { + var last = i == elements.Count - 1; + DescribeElement(elements[i], prefix, last, lines); + } + } + + private static void DescribeElement( + IElement element, + string prefix, + bool last, + List lines + ) + { + var self = prefix + Branch(last); + switch (element) + { + case PlainTextElement { Content: var content }: + lines.Add($"{self}[普通文本] {content}"); + break; + case KMarkdownElement { Content: var content }: + lines.Add($"{self}[KMarkdown] {content}"); + break; + case ImageElement { Source: var source }: + lines.Add($"{self}{Media("图片", null, source)}"); + break; + case ButtonElement { Text: var text }: + lines.Add($"{self}[按钮] {ElementText(text)}"); + break; + case ParagraphStruct { Fields: var fields }: + lines.Add($"{self}[区域文本]"); + AppendElements(fields, prefix + Indent(last), lines); + break; + default: + lines.Add($"{self}[{element.Type}]"); + break; + } + } + + private static string ElementText(IElement element) => + element switch + { + PlainTextElement { Content: var content } => content, + KMarkdownElement { Content: var content } => content, + _ => element.Type.ToString(), + }; + + /// + /// [类型] 文本 链接(无标题时省略文本)。 + /// + private static string Media(string type, string? title, string source) => + string.IsNullOrEmpty(title) ? $"[{type}] {source}" : $"[{type}] {title} {source}"; + + private static string Branch(bool last) => last ? "└─ " : "├─ "; + + private static string Indent(bool last) => last ? " " : "│ "; +} diff --git a/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookOutboundBuilder.cs b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookOutboundBuilder.cs new file mode 100644 index 0000000..ae25ced --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookOutboundBuilder.cs @@ -0,0 +1,95 @@ +using System.Text; +using HuaJiBot.NET.Bot; +using Kook; + +namespace HuaJiBot.NET.Adapter.Kook.Messaging; + +/// +/// 将 HuaJiBot 的消息段序列翻译为 KOOK 发送原语: +/// 文本 / At / 链接合并为 KMarkdown 段,图片单独成段,引用作用于其后第一条消息。 +/// 只做"消息段 -> 发送原语"的映射,不负责实际发送(由适配器编排)。 +/// +internal static class KookOutboundBuilder +{ + public static IReadOnlyList Build(IEnumerable messages) + { + var segments = new List(); + var textBuilder = new StringBuilder(); + IQuote? quote = null; + + void FlushText() + { + if (textBuilder.Length == 0) + return; + segments.Add(new KookTextSegment(textBuilder.ToString(), quote)); + textBuilder.Clear(); + quote = null; // 引用只作用于其后的第一条消息 + } + + foreach (var message in messages) + { + switch (message) + { + case TextMessage { Text: var text }: + textBuilder.Append(text.Sanitize()); + break; + + case AtMessage { Target: var target }: + textBuilder.Append( + ulong.TryParse(target, out var atId) + ? MentionUtils.KMarkdownMentionUser(atId) + : target.Sanitize() + ); + break; + + case LinkMessage { Text: var text, Url: var url }: + textBuilder.Append(Format.Url(text, url)); + break; + + case ReplyMessage { MessageId: var msgId } + when Guid.TryParse(msgId, out var quotedId): + quote = new MessageReference(quotedId); + break; + + case ImageMessage { ImagePath: var path }: + // 图片单独成条,引用(若有)随图片发出。 + FlushText(); + segments.Add(new KookImageSegment(path, quote)); + quote = null; + break; + + default: + throw new NotSupportedException( + $"KOOK 适配器不支持的消息类型:{message.GetType().Name}" + ); + } + } + + FlushText(); + return segments; + } + + /// + /// 从本地路径或 URL 提取文件名(KOOK 附件需要文件名)。 + /// + public static string GetImageFileName(string path) + { + if (Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https") + { + var name = Path.GetFileName(uri.AbsolutePath); + return string.IsNullOrEmpty(name) ? "image.png" : name; + } + return Path.GetFileName(path); + } + + /// + /// 图片路径为 http(s) 链接时复用 KOOK asset,否则作为本地文件上传。 + /// + public static FileAttachment CreateImageAttachment(string path) + { + var fileName = GetImageFileName(path); + return Uri.TryCreate(path, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https" + ? new FileAttachment(uri, fileName, AttachmentType.Image) + : new FileAttachment(path, fileName, AttachmentType.Image); + } +} diff --git a/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookOutboundSegment.cs b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookOutboundSegment.cs new file mode 100644 index 0000000..6692122 --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookOutboundSegment.cs @@ -0,0 +1,9 @@ +using Kook; + +namespace HuaJiBot.NET.Adapter.Kook.Messaging; + +/// +/// 一段待发送的 KOOK 内容。 +/// 表示该段发送时携带的引用(引用只作用于其后的第一条消息)。 +/// +internal abstract record KookOutboundSegment(IQuote? Quote); diff --git a/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookTextSegment.cs b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookTextSegment.cs new file mode 100644 index 0000000..6f5aa5f --- /dev/null +++ b/src/HuaJiBot.NET.Adapter.Kook/Messaging/KookTextSegment.cs @@ -0,0 +1,8 @@ +using Kook; + +namespace HuaJiBot.NET.Adapter.Kook.Messaging; + +/// +/// 合并后的一段 KMarkdown 文本。 +/// +internal sealed record KookTextSegment(string Markdown, IQuote? Quote) : KookOutboundSegment(Quote); diff --git a/src/HuaJiBot.NET.CLI/App.cs b/src/HuaJiBot.NET.CLI/App.cs index 15e1f8f..ac2595d 100644 --- a/src/HuaJiBot.NET.CLI/App.cs +++ b/src/HuaJiBot.NET.CLI/App.cs @@ -2,6 +2,7 @@ using HuaJiBot.NET.Adapter.OneBot; using HuaJiBot.NET.Adapter.Satori; using HuaJiBot.NET.Adapter.Telegram; +using HuaJiBot.NET.Adapter.Kook; using HuaJiBot.NET.Bot; using HuaJiBot.NET.Config; using HuaJiBot.NET.Interfaces; @@ -45,6 +46,7 @@ Config.ServiceType.OneBot => new OneBotAdapter(cfg.OneBot.Url, cfg.OneBot.Token) { Logger = lg }, Config.ServiceType.Satori => new SatoriAdapter(cfg.Satori.Url, cfg.Satori.Token) { Logger = lg }, Config.ServiceType.Telegram => new TelegramAdapter(cfg.Telegram.Token) { Logger = lg }, + Config.ServiceType.Kook => new KookAdapter(cfg.Kook.Token) { Logger = lg }, _ => throw new NotSupportedException("不支持的协议类型"), }; }); @@ -59,8 +61,6 @@ var api = provider.GetRequiredService(); //创建协议适配器 var adapterApi = provider.GetRequiredService(); var internalService = provider.GetRequiredService(); -await internalService.SetupServiceAsync(api, config); //协议适配器 -var pluginManager = new PluginManager(); var accountId = ""; //账号 api.Events.OnBotLogin += (_, eventArgs) => { @@ -71,6 +71,8 @@ ); accountId = eventArgs.Accounts.FirstOrDefault(); }; +await internalService.SetupServiceAsync(api, config); //协议适配器 +var pluginManager = new PluginManager(); var pluginDir = Environment.GetEnvironmentVariable("HUAJIBOT_PLUGIN_DIR") ?? Path.Combine(Environment.CurrentDirectory, "plugins"); //插件目录 diff --git a/src/HuaJiBot.NET.CLI/HuaJiBot.NET.CLI.csproj b/src/HuaJiBot.NET.CLI/HuaJiBot.NET.CLI.csproj index 0a0cb80..fa2972f 100644 --- a/src/HuaJiBot.NET.CLI/HuaJiBot.NET.CLI.csproj +++ b/src/HuaJiBot.NET.CLI/HuaJiBot.NET.CLI.csproj @@ -14,9 +14,17 @@ + + + + PreserveNewest + PreserveNewest + + + diff --git a/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj b/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj index c25559e..3c7a6a4 100644 --- a/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj +++ b/src/HuaJiBot.NET.UnitTest/HuaJiBot.NET.UnitTest.csproj @@ -29,6 +29,7 @@ + diff --git a/src/HuaJiBot.NET.UnitTest/KookAdapterTest.cs b/src/HuaJiBot.NET.UnitTest/KookAdapterTest.cs new file mode 100644 index 0000000..d0f685a --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/KookAdapterTest.cs @@ -0,0 +1,53 @@ +using HuaJiBot.NET.Adapter.Kook; +using HuaJiBot.NET.Bot; +using HuaJiBot.NET.Interfaces; +using HuaJiBot.NET.Logger; + +namespace HuaJiBot.NET.UnitTest; + +internal class KookAdapterTest +{ + private static KookAdapter Adapter() => + new("1/MTA=/token") { Logger = new ConsoleLogger() }; + + [Test] + public void Constructor_AllRobotsEmptyBeforeLogin() => + Assert.That(Adapter().AllRobots, Is.Empty); + + [Test] + public void SetGroupName_OnDirectChannel_ThrowsNotSupported() + { + IMessageService svc = Adapter(); + var chatCode = Guid.NewGuid().ToString(); + Assert.Throws(() => svc.SetGroupName(null, chatCode, "name")); + } + + [Test] + public void SetGroupName_OnInvalidIdentifier_ThrowsArgument() + { + IMessageService svc = Adapter(); + Assert.Throws(() => svc.SetGroupName(null, "not-an-id", "name")); + } + + [Test] + public void GetMemberType_OnDirectChannel_ReturnsUnknown() + { + IAdapterService svc = Adapter(); + var chatCode = Guid.NewGuid().ToString(); + Assert.That(svc.GetMemberType("bot", chatCode, "123"), Is.EqualTo(MemberType.Unknown)); + } + + [Test] + public void GetMemberType_OnInvalidIdentifier_ReturnsUnknown() + { + IAdapterService svc = Adapter(); + Assert.That(svc.GetMemberType("bot", "not-an-id", "123"), Is.EqualTo(MemberType.Unknown)); + } + + [Test] + public void GetNick_OnNonNumericId_ReturnsIdVerbatim() + { + IAdapterService svc = Adapter(); + Assert.That(svc.GetNick("bot", "abc"), Is.EqualTo("abc")); + } +} diff --git a/src/HuaJiBot.NET.UnitTest/KookChannelIdTest.cs b/src/HuaJiBot.NET.UnitTest/KookChannelIdTest.cs new file mode 100644 index 0000000..43261e0 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/KookChannelIdTest.cs @@ -0,0 +1,58 @@ +using HuaJiBot.NET.Adapter.Kook.Channels; + +namespace HuaJiBot.NET.UnitTest; + +internal class KookChannelIdTest +{ + [Test] + public void ParsesDecimalAsGuildChannel() + { + Assert.That(KookChannelId.TryParse("2810246202", out var id), Is.True); + Assert.Multiple(() => + { + Assert.That(id.Kind, Is.EqualTo(KookChannelKind.Guild)); + Assert.That(id.GuildChannelId, Is.EqualTo(2810246202UL)); + }); + } + + [Test] + public void ParsesGuidAsDirectChannel() + { + var chatCode = "3f2504e0-4f89-41d3-9a0c-0305e82c3301"; + Assert.That(KookChannelId.TryParse(chatCode, out var id), Is.True); + Assert.Multiple(() => + { + Assert.That(id.Kind, Is.EqualTo(KookChannelKind.Direct)); + Assert.That(id.DirectChatCode, Is.EqualTo(Guid.Parse(chatCode))); + }); + } + + [TestCase("")] + [TestCase("not-an-id")] + [TestCase(null)] + public void RejectsInvalidIdentifier(string? raw) => + Assert.That(KookChannelId.TryParse(raw, out _), Is.False); + + [Test] + public void GuildFactory_SetsKindAndValue() + { + var id = KookChannelId.Guild(42); + Assert.Multiple(() => + { + Assert.That(id.Kind, Is.EqualTo(KookChannelKind.Guild)); + Assert.That(id.GuildChannelId, Is.EqualTo(42UL)); + }); + } + + [Test] + public void DirectFactory_SetsKindAndValue() + { + var code = Guid.NewGuid(); + var id = KookChannelId.Direct(code); + Assert.Multiple(() => + { + Assert.That(id.Kind, Is.EqualTo(KookChannelKind.Direct)); + Assert.That(id.DirectChatCode, Is.EqualTo(code)); + }); + } +} \ No newline at end of file diff --git a/src/HuaJiBot.NET.UnitTest/KookImageFileNameTest.cs b/src/HuaJiBot.NET.UnitTest/KookImageFileNameTest.cs new file mode 100644 index 0000000..19af649 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/KookImageFileNameTest.cs @@ -0,0 +1,16 @@ +using HuaJiBot.NET.Adapter.Kook.Messaging; + +namespace HuaJiBot.NET.UnitTest; + +internal class KookImageFileNameTest +{ + [TestCase("https://img.example/dir/photo.png", "photo.png")] + [TestCase("https://img.example/dir/photo.png?x=1&y=2", "photo.png")] + [TestCase("C:/tmp/local.jpg", "local.jpg")] + public void ExtractsFileName(string input, string expected) => + Assert.That(KookOutboundBuilder.GetImageFileName(input), Is.EqualTo(expected)); + + [Test] + public void UrlWithoutFileName_FallsBackToDefault() => + Assert.That(KookOutboundBuilder.GetImageFileName("https://img.example/"), Is.EqualTo("image.png")); +} \ No newline at end of file diff --git a/src/HuaJiBot.NET.UnitTest/KookOutboundBuilderTest.cs b/src/HuaJiBot.NET.UnitTest/KookOutboundBuilderTest.cs new file mode 100644 index 0000000..9cb9553 --- /dev/null +++ b/src/HuaJiBot.NET.UnitTest/KookOutboundBuilderTest.cs @@ -0,0 +1,149 @@ +using HuaJiBot.NET.Adapter.Kook.Messaging; +using HuaJiBot.NET.Bot; + +namespace HuaJiBot.NET.UnitTest; + +internal class KookOutboundBuilderTest +{ + private static IReadOnlyList Build(params SendingMessageBase[] messages) => + KookOutboundBuilder.Build(messages); + + [Test] + public void TextMentionLink_MergeIntoSingleTextSegment() + { + var segments = Build( + new TextMessage("hello "), + new AtMessage("123"), + new LinkMessage("site", "https://example.com") + ); + + Assert.That(segments, Has.Count.EqualTo(1)); + var text = segments[0] as KookTextSegment; + Assert.That(text, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(text!.Markdown, Does.StartWith("hello ")); + // 数字目标渲染为提及(含用户 ID),链接渲染为 KMarkdown 链接。 + Assert.That(text.Markdown, Does.Contain("123")); + Assert.That(text.Markdown, Does.Contain("https://example.com")); + Assert.That(text.Quote, Is.Null); + } + } + + [Test] + public void NonNumericMention_FallsBackToSanitizedText() + { + var segments = Build(new AtMessage("everyone")); + + Assert.That(segments, Has.Count.EqualTo(1)); + var text = segments[0] as KookTextSegment; + Assert.That(text, Is.Not.Null); + // 非 ulong 目标不走 MentionUtils,作为文本转义。 + Assert.That(text!.Markdown, Does.Contain("everyone")); + } + + [Test] + public void ImageBetweenTexts_SplitsIntoThreeSegments() + { + var segments = Build( + new TextMessage("before"), + new ImageMessage("https://img.example/a.png"), + new TextMessage("after") + ); + + Assert.That(segments, Has.Count.EqualTo(3)); + using (Assert.EnterMultipleScope()) + { + Assert.That(segments[0], Is.TypeOf()); + Assert.That(segments[1], Is.TypeOf()); + Assert.That(segments[2], Is.TypeOf()); + Assert.That(((KookImageSegment)segments[1]).ImagePath, Is.EqualTo("https://img.example/a.png")); + } + } + + [Test] + public void ConsecutiveImages_EachBecomeOwnSegment() + { + var segments = Build( + new ImageMessage("https://img.example/a.png"), + new ImageMessage("https://img.example/b.png") + ); + + Assert.That(segments, Has.Count.EqualTo(2)); + using (Assert.EnterMultipleScope()) + { + Assert.That(segments[0], Is.TypeOf()); + Assert.That(segments[1], Is.TypeOf()); + } + } + + [Test] + public void SingleImage_ProducesNoEmptyTextSegment() + { + var segments = Build(new ImageMessage("https://img.example/a.png")); + + Assert.That(segments, Has.Count.EqualTo(1)); + Assert.That(segments[0], Is.TypeOf()); + } + + [Test] + public void ReplyBeforeText_QuoteLandsOnTheText() + { + var segments = Build( + new ReplyMessage("11111111-1111-1111-1111-111111111111"), + new TextMessage("reply body") + ); + + Assert.That(segments, Has.Count.EqualTo(1)); + var text = segments[0] as KookTextSegment; + Assert.That(text, Is.Not.Null); + Assert.That(text!.Quote, Is.Not.Null); + } + + [Test] + public void ReplyBeforeImage_QuoteLandsOnTheImage() + { + var segments = Build( + new ReplyMessage("11111111-1111-1111-1111-111111111111"), + new ImageMessage("https://img.example/a.png") + ); + + Assert.That(segments, Has.Count.EqualTo(1)); + var image = segments[0] as KookImageSegment; + Assert.That(image, Is.Not.Null); + Assert.That(image!.Quote, Is.Not.Null); + } + + [Test] + public void ReplyBetweenTexts_MergesIntoOneQuotedTextSegment() + { + var segments = Build( + new TextMessage("first"), + new ReplyMessage("11111111-1111-1111-1111-111111111111"), + new TextMessage("second") + ); + + // Reply 不切分文本:前后文本仍合并为一段,整段带引用。 + Assert.That(segments, Has.Count.EqualTo(1)); + var text = segments[0] as KookTextSegment; + Assert.That(text, Is.Not.Null); + using (Assert.EnterMultipleScope()) + { + Assert.That(text!.Markdown, Is.EqualTo("firstsecond")); + Assert.That(text.Quote, Is.Not.Null); + } + } + + [Test] + public void NonGuidReply_ThrowsNotSupported() => + // 非法 Guid 使 when 守卫落空,落到 default 分支抛异常。 + Assert.Throws( + () => Build(new ReplyMessage("not-a-guid"), new TextMessage("body")) + ); + + [Test] + public void UnsupportedMessageType_Throws() => + Assert.Throws(() => Build(new UnsupportedMessage())); + + private sealed record UnsupportedMessage : SendingMessageBase; +} \ No newline at end of file diff --git a/src/HuaJiBot.NET/Config/Config.cs b/src/HuaJiBot.NET/Config/Config.cs index 20fe8ca..55487c2 100644 --- a/src/HuaJiBot.NET/Config/Config.cs +++ b/src/HuaJiBot.NET/Config/Config.cs @@ -12,6 +12,7 @@ public enum ServiceType OneBot, Satori, Telegram, + Kook, } public ServiceType Service { get; set; } = ServiceType.OneBot; @@ -39,6 +40,13 @@ public class TelegramConnectionInfo public TelegramConnectionInfo Telegram { get; set; } = new(); + public class KookConnectionInfo + { + public string Token { get; set; } = ""; + } + + public KookConnectionInfo Kook { get; set; } = new(); + public string[] ExtraPlugins { get; set; } = []; public Dictionary Plugins { get; set; } = new();