Awesome Async Interfaces with Raku

A tutorial for writing IRC bots with Raku

2015 年圣诞节前后, 我写了我的第一个 Raku 程序 - 新年 IRC 派对机器人。这项工作包括发布了 IRC::Client 模块。我从这个语言中找到了童年的乐趣并且在假期喝了不少酒, 结果就是这个模块最终足够疯狂。

最近, 我需要一个工具来完成一些 Raku 的 bug 队列工作, 因此我决定把自己关上一个周末, 从头开始重新设计和重写这个模块。在过去的几个月里, 有好几个人请求我这么做, 所以我想我也应该写一篇关于如何使用这个模块的教程 - 作为对我这个拖延症大师的道歉。如果你对 IRC 没有兴趣的话, 我希望这篇教程可以作为 Raku 中的异步、非阻塞接口的一个一般示例。

基础

要创建一个 IRC 机器人, 请实例化一个 IRC::Client 对象, 给它一些基本信息, 然后调用 .run 方法。将所有你需要的功能实现为类, 并通过方法名匹配你想要监听的事件, 然后通过 .plugins 属性将其传递给所有的插件。当发生 IRC 事件时, 它会按照你指定的顺序传递给所有的插件, 如果有插件宣称处理了该事件就停止。

这里有一个简单的 IRC 机器人, 它可以响应在频道中找人、通知和发送给它的私人消息。下面这个响应是机器人收到的大写后的原始消息:

use IRC::Client;
.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#raku>
    :debug
    :plugins(class { method irc-to-me ($_) { .text.uc } })

这就是机器人运行时的样子:

<Zoffix> MahBot, I ♥ you!
<MahBot> Zoffix, I ♥ YOU!

:nick, :host:channels 是机器人的昵称、它应该连接的服务器以及它应该加入的频道。:debug 控制要显示多少调试输出。我们在这里将其值设置为 1, 用于显示稀疏的调试输出, 只是为了看看发生了什么。提示:安装可选的 Terminal::ANSIColor 模块可以使调试输出更好看:

img

对于 .plugins 属性, 我们传入了一个匿名类。如果你有多个插件, 只需按照你想让它们接收事件的顺序把它们都塞进去即可:

:plugins(PlugFirst.new, PlugSecond.new(:conf), class { ... })

我们 uppercasing 机器人的插件类有一个单独的方法来监听 irc-to-me 事件, 每当机器人在频道中被寻址或被发送私信或通知时, 就会被触发。它接收单个参数:扮演 IRC::Client::Message 角色的对象之一。我们将其插入到 $_ 主题变量中, 以节省一些输入。

我们通过从这个方法中返回一个值来响应该事件。原文包含在消息对象的 .text 属性中, 所以我们将调用 .uc 方法对内容进行大写, 这就是我们的响应。

就像我们的 uppercasing 机器人一样厉害, 它就像极地考察中的空调一样好用。让我们教给它一些技巧吧。

变得更聪明

我们将调用我们的新插件 Trickster, 它响应命令 time - 这会给出当地的时间和日期 - 以及 temp - 将温度在华氏温度和摄氏温度之间进行转换。下面是代码:

use IRC::Client;

class Trickster {
    method irc-to-me ($_) {
        given .text {
            when /time/ { DateTime.now }
            when /temp \s+ $<temp>=\d+ $<unit>=[F|C]/ {
                when $<unit> eq 'F' { "That's {($<temp> - 32) × .5556}°C" }
                default             { "That's { $<temp> × 1.8 + 32   }°F" }
            }
            'huh?'
        }
    }
}

.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#raku>
    :debug
    :plugins(Trickster)
<Zoffix> MahBot, time
<MahBot> Zoffix, 2016-07-23T19:00:15.795551-04:00
<Zoffix> MahBot, temp 42F
<MahBot> Zoffix, That's 5.556°C
<Zoffix> MahBot, temp 42C
<MahBot> Zoffix, That's 107.6°F
<Zoffix> MahBot, I ♥ you!
<MahBot> Zoffix, huh?

这段代码很简单:我们将给定的文本传递给几个正则表达式。如果它包含单词 time, 我们会返回当前时间。如果它包含单词 temp, 我们会根据给定的数字是由 F 还是 C 后缀来进行适当的计算。而如果没有匹配发生, 我们最终会返回好奇的 huh?

这个新的改进后的插件有一个明显的问题:机器人不再爱我了!虽然我还能熬过心痛, 但我怀疑任何其他插件都不会再教导机器人爱我了, 因为 Trickster 会消耗掉所有的 irc-to-me 事件, 即使它不识别任何它能处理的命令。让我们来解决这个问题吧!

传递美元

事件处理程序可以返回一个特殊的值, 以表示它没有处理该事件, 并且应该将其传播给其它插件和事件处理程序。这个值是由 IRC::Client::Plugin 角色提供的 .NEXT 属性提供的, 插件可以 does 获取这个属性。当你使用 IRC::Client 时, 这个角色会自动导出。

让我们来看看一些利用这个特殊值的代码。请注意, 由于 .NEXT 是一个属性, 而我们无法在类型对象上查找属性, 所以你需要多走一步, 在将插件类实例化的时候, 将它们传递给 :plugins

use IRC::Client;

class Trickster does IRC::Client::Plugin {
    method irc-to-me ($_) {
        given .text {
            when /time/ { DateTime.now }
            when /temp \s+ $<temp>=\d+ $<unit>=[F|C]/ {
                when $<unit> eq 'F' { "That's {($<temp> - 32) × .5556}°C" }
                default             { "That's { $<temp> × 1.8 + 32   }°F" }
            }
            $.NEXT;
        }
    }
}

class BFF does IRC::Client::Plugin {
    method irc-to-me ($_) {
        when .text ~~ /'♥'/ { 'I ♥ YOU!' };
        $.NEXT;
    }
}

.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#raku>
    :debug
    :plugins(Trickster.new, BFF.new)
<Zoffix> MahBot, time
<MahBot> Zoffix, 2016-07-23T19:37:45.788272-04:00
<Zoffix> MahBot, temp 42F
<MahBot> Zoffix, That's 5.556°C
<Zoffix> MahBot, temp 42C
<MahBot> Zoffix, That's 107.6°F
<Zoffix> MahBot, I ♥ you!
<MahBot> Zoffix, I ♥ YOU!

我们现在有两个插件都订阅了 irc-to-me 事件。 :plugins 属性首先接收 Trickster 插件, 所以它的事件处理程序将首先运行。如果接收到的文本与 Trickster 的任何一个正则表达式中的都不匹配, 它将从该方法返回 $.NEXT

这就给客户端对象发出了寻找其他处理程序的信号, 所以它就会到达 BFFirc-to-me 处理程序。在那里, 如果输入中是否包含爱心, 我们就会回复, 如果没有, 我们也会在这里预先返回 $.NEXT

当机器人恢复了它的晴朗的性格, 但它是以额外的打字为代价的。我们能做些什么呢?

万物多变

Raku 支持多重分派以及签名中的类型约束。除此之外, 如果 IRC::Client 的消息对象有 .text 属性, 那么 smartmatch 就会使用该属性的值。将这三个特性结合起来, 你就可以得到一个非常简洁的代码:

use IRC::Client;
class Trickster {
    multi method irc-to-me ($ where /time/) { DateTime.now }
    multi method irc-to-me ($ where /temp \s+ $<temp>=\d+ $<unit>=[F|C]/) {
        $<unit> eq 'F' ?? "That's {($<temp> - 32) × .5556}°C"
                       !! "That's { $<temp> × 1.8 + 32   }°F"
    }
}

class BFF { method irc-to-me ($ where /'♥'/) { 'I ♥ YOU!' } }

.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#raku>
    :debug
    :plugins(Trickster, BFF)
<Zoffix> MahBot, time
<MahBot> Zoffix, 2016-07-23T19:59:44.481553-04:00
<Zoffix> MahBot, temp 42F
<MahBot> Zoffix, That's 5.556°C
<Zoffix> MahBot, temp 42C
<MahBot> Zoffix, That's 107.6°F
<Zoffix> MahBot, I ♥ you!
<MahBot> Zoffix, I ♥ YOU!

在签名之外, 我们不再需要消息对象, 所以我们可以使用匿名 $ 参数来代替它。然后, 我们使用正则表达式匹配的类型来约束这个参数, 因此只有当消息的文本与这个正则表达式匹配时, 方法才会被调用。因为没有方法会在失败时被调用, 所以我们不再需要在插件中加入 $.NEXT, 也不需要在插件中添加任何角色。

我们方法的主体都有一个单独的语句产生事件的响应值。在温度转换器中, 我们使用三元运算符来选择转换时使用哪个公式, 这取决于所请求的单位, 是的, 在签名类型约束匹配中创建的 $<unit>$<temp> 捕获可用于该方法的主体。

多事之秋

除了标准的命名和数字 IRC 协议事件外, IRC::Client 还提供了方便事件。其中一个我们已经看到过:irc-to-me 事件。这样的事件是分层的, 所以一个 IRC 事件可以触发多个 IRC::Client 的事件。例如, 如果有人在频道中 @ 我们的机器人, 就会触发以下事件链:

irc-addressed  ▶  irc-to-me  ▶  irc-privmsg-channel  ▶  irc-privmsg  ▶  irc-all

这些事件从"最窄"到"最宽"排列:当我们的机器人被 @ 时, 才会在频道中触发 irc-addressedirc-to-me 也可以通过通知和私信触发, 所以它的范围更宽; irc-privmsg-channel 包含所有频道消息, 所以它的范围仍然更宽; 而 irc-privmsg 也包含给我们的机器人的私信。这条链以其中最宽的事件结束:irc-all

如果插件的事件处理程序返回 $.NEXT 以外的任何值, 那么事件链后面的事件就不会被触发, 就像插件链中后面的插件也不会因为同样的原因而被尝试一样。每个事件都会在所有的插件上进行尝试, 然后再尝试处理更宽的事件。

通过将 :debug 属性设置为 3 级或更高的级别, 你会在调试输出中得到发射的事件。下面是我们的机器人尝试处理未知的命令 blarg, 然后处理由我们定义的 irc-to-me 事件处理程序处理的命令 time

img

IRC::Client 的所有事件都有 irc- 前缀, 所以你可以在插件中自由定义辅助方法, 不用担心与事件处理程序冲突。说到发出的东西……

让它们来吧!

响应命令是很好的, 但很多机器人可能会希望自己主动产生一些输出。作为一个例子, 让我们写一个机器人, 每当我们有未读的 GitHub 通知时, 它会来过来烦我们!

use IRC::Client;
use HTTP::Tinyish;
use JSON::Fast;

class GitHub::Notifications does IRC::Client::Plugin {
    has Str  $.token  = %*ENV<GITHUB_TOKEN>;
    has      $!ua     = HTTP::Tinyish.new;
    constant $API_URL = 'https://api.github.com/notifications';

    method irc-connected ($) {
        start react {
            whenever self!notification.grep(* > 0) -> $num {
                $.irc.send: :where<Zoffix>
                            :text("You have $num unread notifications!")
                            :notice;
            }
        }
    }

    method !notification {
        supply {
            loop {
                my $res = $!ua.get: $API_URL, :headers{ :Authorization("token $!token") };
                $res<success> and emit +grep *.<unread>, |from-json $res<content>;
                sleep $res<headers><X-Poll-Interval> || 60;
            }
        }
    }
}

.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#raku>
    :debug
    :plugins(GitHub::Notifications.new)
[00:25:41] -MahBot- Zoffix, You have 20 unread notifications!
[00:26:41] -MahBot- Zoffix, You have 19 unread notifications!

我们创建了一个 does IRC::Client::Plugin 角色的 GitHub::Notifications 类。这个角色为我们提供了 $.irc 属性, 这是我们将用于在 IRC 上向我们发送消息的 IRC::Client 对象。

除了 irc-connected 方法之外, 这个类也和其他类一样:用于我们的 GitHub API token 的公共 $.token 属性, 用于保留 HTTP 用户代理对象的私有 $!ua 属性以及私有的 notification 方法, 所有的操作都是在这里发生的。

notification 内部, 我们创建了一个 Supply, 它将发布未读通知的数量。它通过使用 HTTP::Tinyish 对象来访问 GitHub API 端点。在第 24 行, 它解析成功请求返回的 JSON, 并在消息列表中搜索任何 unread 属性设置为 true 的项目。前缀 + 运算符将列表转换为一个 Int, 即找到的总项目, 这也是我们从 supply 中 emit 的内容。

当我们成功连接到 IRC 服务器时, 会触发 irc-connected 的事件处理程序。其中, 我们启动(start)一个事件循环, 当我们收到由 notifications 方法给出的当前未读消息数时, 就会启动一个事件循环进行响应。因为我们只对有未读消息的情况感兴趣, 所以我们也会在 supply 上弹出一个 grep 来过滤掉没有消息的情况(没错, 我们可以在第一时间避免发送这些消息, 但我只是在这里炫耀一下 Raku 😸)。一旦有未读消息, 我们只需调用 IRC::Client.send 方法, 让它发送一条 IRC 通知, 并注明未读消息的总数。真是太神奇了!

别等了

我们涵盖了我们发送到 IRC 的异步数据供应值, 或者马上回复命令的情况。机器人命令需要一些时间来执行的情况并不少见。在这些情况下, 我们不希望在命令执行的过程中, 机器人被锁定。

多亏了 Raku 的优秀的并发原语, 它就不必这样做了!如果事件处理程序返回一个 Promise, 则客户端对象将在保存(kept)时使用它的 .result 作为回复。这意味着, 为了使我们的阻塞事件处理程序不阻塞, 我们要做的就是把它的主体封装在一个 start {...} 块中。还有什么能比这更简单呢?

作为一个例子, 让我们来写一个机器人来响应 bash 命令。该机器人将获取 bash.org/?random1, 解析出 HTML 中的引号, 并将其保存在缓存中。当命令被触发时, 机器人会把其中的一个引号递送出去, 当缓存用完时, 重复提取。特别是, 我们不希望机器人在检索和解析网页的过程中出现阻塞。下面是完整的代码:

use IRC::Client;
use Mojo::UserAgent:from<Perl5>;

class Bash {
    constant $BASH_URL = 'http://bash.org/?random1';
    constant $cache    = Channel.new;
    has        $!ua    = Mojo::UserAgent.new;

    multi method irc-to-me ($ where /bash/) {
        start $cache.poll or do { self!fetch-quotes; $cache.poll };
    }

    method !fetch-quotes {
        $cache.send: $_
            for $!ua.get($BASH_URL).res.dom.find('.qt').each».all_text.lines.join: '  ';
    }
}

.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#raku>
    :debug
    :plugins(Bash.new)
<Zoffix> MahBot, bash
<MahBot> Zoffix, <Time> that reminds me of when Manning and I installed OS/2 Warp4 on a box and during the install routine it said something to the likes of 'join the hundreds of people on the internet'

为了满足页面抓取的需求, 我选择了 Perl 5 的 Mojo::UserAgent, 因为它有内置了一个 HTML 解析器。 :from<Perl5> 副词向编译器指示我们要加载一个 Perl 5 模块, 而不是 Raku 模块。

由于我们是多线程的, 因此我们将使用一个 Channel 作为线程安全队列来进行缓存。我们订阅了文字包含单词 bashirc-to-me 事件。当事件处理程序被触发时, 我们使用 start 关键字弹出到一个新的线程。然后, 我们会轮询(.poll)我们的缓存并使用缓存值(如果我们有缓存值的话), 否则, 逻辑将转移到调用 fetch-quotes 私有方法的 do 块上, 并在完成时再次轮询缓存, 获得新的引用。所有的事情完成后, 引用将是我们从事件处理程序返回的 Promise 的结果。

fetch-quotes 方法启动我们的 Mojo::UserAgent 对象, 从网站上获取随机引用页面, 找到所有带 class="qt" 的 HTML 元素 - 这些是带引号的段落。然后, 我们使用一个 hyper 方法调用将这些段落转换为文本, 并通过 for 循环将最终列表提供给我们的 $cache Channel。就这样, 我们将我们的机器人无阻塞地连接到 IRC 世界的宿主上了。说到这里, 你可能要过滤一下…

注意你的言行!

如果我们的机器人向频道喷出大量的输出, 我们的机器人很快就会被禁止。一个明显的解决方案是在我们的插件中加入逻辑, 如果输出量过大, 就会使用 pastebin。然而, 在我们编写的每一个插件中添加这样的东西是非常不现实的。幸运的是, IRC::Client 已经支持过滤器了!

对于任何发出 NOTICEPRIVMSG IRC 命令的方法, IRC::Client 都会通过 :filters 属性将输出通过类传递给它 。这意味着我们可以设置一个过滤器, 无论它来自哪个插件, 都会自动粘贴大容量的输出。

我们将重新使用我们的 bash.org 引用机器人, 只是这次它会将大量引用粘贴到 Shadowcat pastebin 中。让我们来看看一些代码吧!

use IRC::Client;
use Pastebin::Shadowcat;
use Mojo::UserAgent:from<Perl5>;

class Bash {
    constant $BASH_URL = 'http://bash.org/?random1';
    constant $cache    = Channel.new;
    has        $!ua    = Mojo::UserAgent.new;

    multi method irc-to-me ($ where /bash/) {
        start $cache.poll or do { self!fetch-quotes; $cache.poll };
    }

    method !fetch-quotes {
        $cache.send: $_
            for $!ua.get($BASH_URL).res.dom.find('.qt').each».all_text;
    }
}

.run with IRC::Client.new:
    :nick<MahBot>
    :host<irc.freenode.net>
    :channels<#zofbot>
    :debug
    :plugins(Bash.new)
    :filters(
        -> $text where .lines > 1 || .chars > 300 {
            Pastebin::Shadowcat.new.paste: $text.lines.join: "\n";
        }
    )
<Zoffix> MahBot, bash
<MahBot> Zoffix, <intuit> hmm maybe sumtime next week i will go outside'
<Zoffix> MahBot, bash
<MahBot> Zoffix, http://fpaste.scsys.co.uk/528741

完成所有过滤工作的代码很小, 很容易出错 - 就是上面程序中的最后5行。 :filters 属性接收一个 Callables 列表, 这里我们传递了一个尖号块。在它的签名中, 我们将文本限制为超过 1 行或超过 300 个字符, 因此只有满足这些条件时, 我们的过滤器才会运行。在这个块中, 我们只需使用 Pastebin::Shadowcat 模块将输出扔到 pastebin 中。它的 .paste 方法返回新创建的粘贴的 URL, 我们的过滤器将用它来替换原始内容。非常棒!

像黄油一样扩散

在过去, 当我使用其他 IRC 客户端工具时, 每当有人要求我把机器人放到其他服务器上时, 过程很简单:把代码复制到另一个目录下, 修改配置, 就可以了。这几乎是有道理的, 新服务器意味着一个"新" 的机器人:不同的频道、不同的昵称等等。

在 Raku 的 IRC::Client 中, 我尝试重新想象了一下:服务器只是消息的另一个标识符, 还有一个频道或昵称。这意味着把你的机器人连接到多个服务器, 就如同通过 :server 属性添加新的服务器配置一样简单:

use IRC::Client;

class BFF {
    method irc-to-me ($ where /'♥'/) { 'I ♥ YOU!' }
}

.run with IRC::Client.new:
    :debug
    :plugins(BFF)
    :nick<MahBot>
    :channels<#zofbot>
    :servers(
        freenode => %(
            :host<irc.freenode.net>,
        ),
        local => %(
            :nick<P6Bot>,
            :channels<#zofbot #raku>,
            :host<localhost>,
        )
    )
[on Freenode server]
<ZoffixW> MahBot, I ♥ you
<MahBot> ZoffixW, I ♥ YOU!

[on local server]
<ZoffixW> P6Bot, I ♥ you
<P6Bot> ZoffixW, I ♥ YOU!

首先, 我们的插件仍然没有意识到它正在在多个服务器上运行。它的回复被重定向到正确的服务器, IRC::Client 仍然以线程安全的方式执行它的方法处理程序。

IRC::Client 的构造函数中, 我们添加了接收 Hash:servers 属性。这个 Hash 的键是服务器的标签, 值是服务器特定的配置, 可覆盖全局设置。所以, freenode 服务器从我们提供给 IRC::Client:nick:channels 属性获取它的 :nick:channels 属性, 而 local 服务器使用它自己的值覆盖这些属性。

现在, 调试输出中会打印出服务器标签, 以指示该事件适用于哪个服务器:

img

就这样, 但只是简单地告诉机器人连接到另一台服务器, 我们就把它变成了多服务器, 而没有对我们的插件做任何改动。但是, 当我们真的要和特定的服务器对话时, 我们该怎么做呢?

发送它的方式

当机器人被 .run 时, 客户端对象会将 :servers 属性的值变为 IRC::Client::Server 对象。这些字符串化为它们所代表的服务器的标签, 我们可以从消息对象的 .server 属性或客户端对象的 .servers hash 属性中获取。客户端对象的方法(如 .send.join)都接收一个可选的 server 属性来控制消息将被发送到哪个服务器, 默认值为 *, 也就是发送到每个服务器。

这里有一个连接到两台服务器并加入几个频道的机器人。每当它看到一条频道消息时, 它就会把它转发到其他所有的频道, 并在由 local 标签指定的服务器上向用户 Zoffix 发送一条私信。

use IRC::Client;

class Messenger does IRC::Client::Plugin {
    method irc-privmsg-channel ($e) {
        for $.irc.servers.values -> $server {
            for $server.channels -> $channel {
                next if $server eq $e.server and $channel eq $e.channel;

                $.irc.send: :$server, :where($channel), :text(
                    "$e.nick() over at $e.server.host()/$e.channel() says $e.text()"
                );
            }
        }

        $.irc.send: :where<Zoffix>
                    :text('I spread the messages!')
                    :server<local>;
    }
}

.run with IRC::Client.new:
    :debug
    :plugins[Messenger.new]
    :nick<MahBot>
    :channels<#zofbot>
    :servers{
        freenode => %(
            :host<irc.freenode.net>,
        ),
        local => %(
            :nick<P6Bot>,
            :channels<#zofbot #raku>,
            :host<localhost>,
        )
    }
[on Freenode server/#zofbot]
<ZoffixW> Yey!
[on local server/#zofbot]
<P6Bot> ZoffixW over at irc.freenode.net/#zofbot says Yey!
[on local server/#raku]
<P6Bot> ZoffixW over at irc.freenode.net/#zofbot says Yey!
[on local server/ZoffixW private message queue]
<P6Bot> I spread the messages!

我们订阅了 irc-privmsg-channel 事件, 当它被触发时, 我们会遍历所有的服务器。对于每台服务器, 我们循环遍历所有连接的频道, 并使用 $.irc.send 方法向该频道和服务器发送消息, 除非服务器和频道与消息的来源相同。

消息本身会调用消息对象上的 .nick, .channel.server.host 方法来识别消息的发送者和来源。

结论

Raku 提供了强大的并发原语, 调度方法和自省功能, 使你可以构建出令人惊叹的非阻塞、基于事件的接口。IRC::Client 就是其中之一, 它可以让你使用 IRC 网络。它就在这里。它 已经准备好了。使用它吧!

原文地址: https://perl6.party/post/IRC-Client-Perl-6-Multi-Server-IRC-Module

Raku  IRC 

comments powered by Disqus