Skip to content

Commit

Permalink
Implement text command framework
Browse files Browse the repository at this point in the history
  • Loading branch information
gehongyan committed Oct 12, 2024
1 parent 56d375a commit f1b221b
Show file tree
Hide file tree
Showing 134 changed files with 6,369 additions and 238 deletions.
16 changes: 16 additions & 0 deletions QQBot.Net.sln
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QQBot.Net.Samples.SimpleBot
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QQBot.Net", "src\QQBot.Net\QQBot.Net.csproj", "{3F892191-3F31-4611-B82F-EB438231BAB8}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Extensions", "Extensions", "{4A63417D-A222-4AFF-8A1B-E095A42B6E02}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QQBot.Net.Commands", "src\QQBot.Net.Commands\QQBot.Net.Commands.csproj", "{A4746508-CF2B-4415-B1BA-E3E30AF0047F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QQBot.Net.Samples.TextCommands", "samples\QQBot.Net.Samples.TextCommands\QQBot.Net.Samples.TextCommands.csproj", "{DBDC171D-0150-4C3D-9DD9-FC97CE9415D9}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -40,10 +46,20 @@ Global
{3F892191-3F31-4611-B82F-EB438231BAB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3F892191-3F31-4611-B82F-EB438231BAB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3F892191-3F31-4611-B82F-EB438231BAB8}.Release|Any CPU.Build.0 = Release|Any CPU
{A4746508-CF2B-4415-B1BA-E3E30AF0047F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A4746508-CF2B-4415-B1BA-E3E30AF0047F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A4746508-CF2B-4415-B1BA-E3E30AF0047F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A4746508-CF2B-4415-B1BA-E3E30AF0047F}.Release|Any CPU.Build.0 = Release|Any CPU
{DBDC171D-0150-4C3D-9DD9-FC97CE9415D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DBDC171D-0150-4C3D-9DD9-FC97CE9415D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DBDC171D-0150-4C3D-9DD9-FC97CE9415D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DBDC171D-0150-4C3D-9DD9-FC97CE9415D9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{7D9057A1-2A23-4247-856B-F1EAE51520CD} = {F3FFC392-8433-4FDF-9FE8-F1033E90C01D}
{1C238CD2-C367-46C3-B9FD-EC71B513466B} = {F3FFC392-8433-4FDF-9FE8-F1033E90C01D}
{CB517FD2-3D74-41CA-8E2F-891562316333} = {74B44251-195F-4884-8F6B-689CD282AF8D}
{A4746508-CF2B-4415-B1BA-E3E30AF0047F} = {4A63417D-A222-4AFF-8A1B-E095A42B6E02}
{DBDC171D-0150-4C3D-9DD9-FC97CE9415D9} = {74B44251-195F-4884-8F6B-689CD282AF8D}
EndGlobalSection
EndGlobal
98 changes: 98 additions & 0 deletions samples/QQBot.Net.Samples.TextCommands/Modules/PublicModule.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
using QQBot.Commands;
using QQBot.Net.Samples.TextCommands.Services;
using QQBot.WebSocket;

namespace QQBot.Net.Samples.TextCommands.Modules;

// Modules must be public and inherit from an IModuleBase
public class PublicModule : ModuleBase<SocketCommandContext>
{
// Dependency Injection will fill this value in for us
public required PictureService PictureService { get; set; }

[Command("ping")]
[Alias("pong", "hello", "test")]
public Task PingAsync() =>
ReplyAsync("pong!");

[Command("cat", RunMode = RunMode.Async)]
[RequireContext(ContextType.Guild | ContextType.DM)]
public async Task CatAsync()
{
// Get a stream containing an image of a cat
Stream stream = await PictureService.GetCatPictureAsync();
// Streams must be seeked to their beginning before being uploaded!
stream.Seek(0, SeekOrigin.Begin);
FileAttachment attachment = new(stream, "cat.png");
await ReplyAsync(attachment: attachment);
}

// Get info on a user, or the user who invoked the command if one is not specified
// [Command("userinfo")]
// public async Task UserInfoAsync(IUser? user = null)
// {
// user ??= Context.User;
// await ReplyAsync(user.ToString() ?? user.Id);
// }

// [Command("emoji")]
// public async Task Emoji([Remainder] string? _) =>
// await Context.Message.AddReactionAsync(new Emoji("\uD83D\uDC4C"));

// [Command("image")]
// public async Task Image(Uri image)
// {
// if (Context.Message.MaybeTextImageMixedMessage()
// && image.IsAbsoluteUri)
// await ReplyFileAsync(new FileAttachment(image, "image.png", AttachmentType.Image));
// }

// // Ban a user
// [Command("ban")]
// [RequireContext(ContextType.Guild)]
// // make sure the user invoking the command can ban
// [RequireUserPermission(GuildPermission.BanMembers)]
// // make sure the bot itself can ban
// [RequireBotPermission(GuildPermission.BanMembers)]
// public async Task BanUserAsync(IGuildUser user, [Remainder] string? reason = null)
// {
// await user.Guild.AddBanAsync(user, reason: reason);
// await ReplyAsync("ok!");
// }

// [Remainder] takes the rest of the command's arguments as one argument, rather than splitting every space
[Command("echo")]
public Task EchoAsync([Remainder] string text) =>
// Insert a ZWSP before the text to prevent triggering other bots!
ReplyAsync('\u200B' + text);

// 'params' will parse space-separated elements into a list
[Command("list")]
public Task ListAsync(params string[] objects) =>
ReplyAsync($"You listed: {string.Join("; ", objects)}");

// Setting a custom ErrorMessage property will help clarify the precondition error
[Command("guild_only")]
[RequireContext(ContextType.Guild,
ErrorMessage = "Sorry, this command must be ran from within a server, not a DM!")]
public Task GuildOnlyCommand() =>
ReplyAsync("Nothing to see here!");

// [Command("per")]
// public async Task ModifyCategoryPermissions()
// {
// if (Context.Guild is not { } guild) return;
// if (Context.Channel is not IGuildChannel guildChannel) return;
// await guildChannel.AddPermissionOverwriteAsync((IGuildUser)Context.User);
// if (guildChannel is SocketChannel socketChannel)
// await socketChannel.UpdateAsync();
// if (guild.GetChannel(Context.Channel.Id) is { } socketGuildChannel)
// {
// await socketGuildChannel.ModifyPermissionOverwriteAsync((IGuildUser)Context.User,
// permissions => permissions.Modify(
// viewChannel: PermValue.Allow,
// sendMessages: PermValue.Deny,
// attachFiles: PermValue.Allow));
// }
// }
}
37 changes: 37 additions & 0 deletions samples/QQBot.Net.Samples.TextCommands/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using QQBot;
using QQBot.Commands;
using QQBot.Net.Samples.TextCommands.Services;
using QQBot.WebSocket;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

// 这是一个使用 QQBot.Net 的文本命令框架的简单示例

// 此处使用了 .NET 通用主机承载 KOOK Bot 服务,该主机将帮助我们管理应用程序与服务的依赖注入与生命周期。
// 有关 .NET 通用主机的更多信息,请参阅 https://learn.microsoft.com/dotnet/core/extensions/generic-host
// 如果您使用另一个依赖注入框架,应该查阅其文档以找到最佳的处理方式。

// 您可以在以下位置找到使用命令框架的文档:
// - https://kooknet.dev/guides/text_commands/intro.html

HostApplicationBuilder builder = Host.CreateEmptyApplicationBuilder(new HostApplicationBuilderSettings());

builder.Services.AddSingleton<QQBotSocketConfig>(_ => new QQBotSocketConfig
{
LogLevel = LogSeverity.Debug,
AccessEnvironment = AccessEnvironment.Sandbox,
GatewayIntents = GatewayIntents.All
});
builder.Services.AddSingleton<QQBotSocketClient>(provider =>
{
QQBotSocketConfig config = provider.GetRequiredService<QQBotSocketConfig>();
return new QQBotSocketClient(config);
});
builder.Services.AddSingleton<CommandService>();
builder.Services.AddSingleton<CommandHandlingService>();
builder.Services.AddHostedService<QQBotClientService>();
builder.Services.AddSingleton<PictureService>();
builder.Services.AddHttpClient("Pictures");

IHost app = builder.Build();
await app.RunAsync();
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">

<Import Project="../../QQBot.Net.Sample.props" />

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using System.Reflection;
using QQBot.Commands;
using QQBot.WebSocket;
using Microsoft.Extensions.DependencyInjection;

namespace QQBot.Net.Samples.TextCommands.Services;

public class CommandHandlingService
{
private readonly CommandService _commands;
private readonly QQBotSocketClient _client;
private readonly IServiceProvider _services;

public CommandHandlingService(IServiceProvider services)
{
_commands = services.GetRequiredService<CommandService>();
_client = services.GetRequiredService<QQBotSocketClient>();
_services = services;

// Hook CommandExecuted to handle post-command-execution logic.
_commands.CommandExecuted += CommandExecutedAsync;
// Hook MessageReceived so we can process each message to see
// if it qualifies as a command.
_client.MessageReceived += MessageReceivedAsync;
}

public async Task InitializeAsync()
{
if (Assembly.GetEntryAssembly() is not { } assembly) return;
// Register modules that are public and inherit ModuleBase<T>.
await _commands.AddModulesAsync(assembly, _services);
}

public async Task MessageReceivedAsync(SocketUserMessage message)
{
if (_client.CurrentUser is null) return;

// Ignore system messages, or messages from other bots
if (message.Source is not MessageSource.User) return;

// This value holds the offset where the prefix ends
int argPos = 0;
// Perform prefix check. The default formats in various contexts are:
if (message.Channel is IGuildChannel)
if (!message.HasMentionPrefix(_client.CurrentUser, ref argPos)) return;
if (message.Channel is IGroupChannel or IGuildChannel)
if (!message.HasCharPrefix('/', ref argPos)) return;
if (!message.HasCharPrefix('/', ref argPos)) return;
// for a more traditional command format like !help.
// if (!message.HasMentionPrefix(_client.CurrentUser, ref argPos))
// return;

SocketCommandContext context = new(_client, message);
// Perform the execution of the command. In this method,
// the command service will perform precondition and parsing check
// then execute the command if one is matched.
await _commands.ExecuteAsync(context, argPos, _services);
// Note that normally a result will be returned by this format, but here
// we will handle the result in CommandExecutedAsync,
}

public async Task CommandExecutedAsync(CommandInfo? command, ICommandContext context, IResult result)
{
// command is unspecified when there was a search failure (command not found); we don't care about these errors
if (command is null)
return;

// the command was successful, we don't care about this result, unless we want to log that a command succeeded.
if (result.IsSuccess)
return;

// the command failed, let's notify the user that something happened.
await context.Message.ReplyAsync($"error: {result}");
}
}
11 changes: 11 additions & 0 deletions samples/QQBot.Net.Samples.TextCommands/Services/PictureService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
namespace QQBot.Net.Samples.TextCommands.Services;

public class PictureService(IHttpClientFactory httpClientFactory)
{
public async Task<Stream> GetCatPictureAsync()
{
HttpClient httpClient = httpClientFactory.CreateClient("Pictures");
HttpResponseMessage resp = await httpClient.GetAsync("https://cataas.com/cat");
return await resp.Content.ReadAsStreamAsync();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using QQBot.Commands;
using QQBot.WebSocket;
using Microsoft.Extensions.Hosting;

namespace QQBot.Net.Samples.TextCommands.Services;

public class QQBotClientService : IHostedService
{
private readonly QQBotSocketClient _client;
private readonly CommandService _commandService;
private readonly CommandHandlingService _commandHandlingService;

public QQBotClientService(QQBotSocketClient client,
CommandService commandService, CommandHandlingService commandHandlingService)
{
_client = client;
_commandService = commandService;
_commandHandlingService = commandHandlingService;
}

/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
_client.Log += LogAsync;
_commandService.Log += LogAsync;

// 令牌(Tokens)应被视为机密数据,永远不应硬编码在代码中
// 在实际开发中,为了保护令牌的安全性,建议将令牌存储在安全的环境中
// 例如本地 .json、.yaml、.xml、.txt 文件、环境变量或密钥管理系统
// 这样可以避免将敏感信息直接暴露在代码中,以防止令牌被滥用或泄露
string token = Environment.GetEnvironmentVariable("QQBotDebugToken", EnvironmentVariableTarget.User)
?? throw new InvalidOperationException("Token not found");
await _client.LoginAsync(0, TokenType.BotToken, token);
await _client.StartAsync();
await _commandHandlingService.InitializeAsync();
}

/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
await _client.StopAsync();
await _client.LogoutAsync();
}

/// <summary>
/// Log 事件,此处以直接输出到控制台为例
/// </summary>
private static Task LogAsync(LogMessage log)
{
Console.WriteLine(log.ToString());
return Task.CompletedTask;
}
}
4 changes: 4 additions & 0 deletions src/QQBot.Net.Commands/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;

[assembly: InternalsVisibleTo("QQBot.Net.Tests.Unit")]
[assembly: InternalsVisibleTo("QQBot.Net.Tests.Integration.Rest")]
35 changes: 35 additions & 0 deletions src/QQBot.Net.Commands/Attributes/AliasAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace QQBot.Commands;

/// <summary>
/// 为命令指定别名。
/// </summary>
/// <remarks>
/// 此特性允许命令具有一个或多个别名,在指定命令的基本名称的同时,还可以指定多个别名,以便用户可以使用多个熟悉的词汇来触发相同的命令。
/// </remarks>
/// <example>
/// 以下示例中,要调用此命令,除了可以使用基本名称“stats”,还使用“stat”或“info”。
/// <code language="cs">
/// [Command("stats")]
/// [Alias("stat", "info")]
/// public async Task GetStatsAsync(IUser user)
/// {
/// await ReplyTextAsync($"{user.Username} has 1000 score!");
/// }
/// </code>
/// </example>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AliasAttribute : Attribute
{
/// <summary>
/// 获取为命令定义的别名。
/// </summary>
public string[] Aliases { get; }

/// <summary>
/// 初始化一个 <see cref="AliasAttribute"/> 类的新实例。
/// </summary>
public AliasAttribute(params string[] aliases)
{
Aliases = aliases;
}
}
Loading

0 comments on commit f1b221b

Please sign in to comment.