65.9K
CodeProject 正在变化。 阅读更多。
Home

将新的 CodeProject 内容发布到 Slack 工作区

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2019年2月5日

CPOL

21分钟阅读

viewsIcon

10383

downloadIcon

107

此应用程序使用 Slack API 和 CodeProject API 监控 CodeProject 的新内容,并在指定的 Slack 频道中发布更新。

目录

引言

本文是我参加Slack API 挑战赛[^] 的作品。

您想在您的 Slack 工作区中获取新的 CodeProject 文章信息吗?或者内部新闻[^]中的所有新消息?或者 Quick Answers 中所有新的 C# 问题?此应用程序使用CodeProject API[^] 监控 CodeProject 的新内容并将其发布到 Slack 工作区。

目标

  1. CodeProject API 会定期轮询新内容:文章、问题以及指定论坛中的消息。可选地,只查看带有某些用户指定标签的文章和问题。新内容的链接会发布到 Slack 工作区。
  2. 所有设置都可以通过与 Slack 工作区中运行的机器人交互来更改。这些设置是:
    • 发布更新的 Slack 频道
    • 轮询新内容的时间间隔
    • 文章标签
    • 问题标签
    • 检查新消息的论坛

在 Slack 上设置应用程序和机器人

转到您的应用[^] 并点击“创建新应用”。您将被要求输入应用名称和应用的工作区

创建应用后,您将看到此页面

首先,转到“权限”并选择权限范围。此应用程序需要两个范围:chat:write:botbot

然后,转到“机器人”设置机器人用户。填写详细信息并点击“添加机器人用户”。

下一步是将应用安装到您的工作区。返回“权限”并点击此按钮

安装后,您可以找到您的工作区的令牌。您会看到两个令牌:OAuth 访问令牌和机器人用户 OAuth 访问令牌。对于此应用程序,您只需要机器人用户 OAuth 访问令牌(在截图中用绿色标记)。

机器人用户将与 Slack 的实时消息 API 进行通信,但要使其工作,您必须将机器人邀请到您希望它监听消息的频道

为 CodeProject API 设置客户端

使用 CodeProject API 需要在CodeProject 上注册您的客户端[^]。注册后,您可以获取您的客户端 ID 和客户端密钥。这两个值是 CodeProject API 身份验证所必需的。

现在您可以运行应用程序了!(要运行它,请先构建可下载的项目。当您运行它时,系统会提示您输入必要的身份验证值和应用程序可以存储某些设置的文件名。作为提示这些值的替代方案,您可以将它们存储在 JSON 文件中,如“Main 方法”中所述,并将此 JSON 文件的路径作为命令行参数传递。当应用程序运行时,根据需要调整其设置以获取新内容。在机器人监听信息的频道中发布 !codeproject help,或者参阅 MessageHandler 类)。

代码

此应用程序是用 C# 编写的,运行在 .NET Core 上。代码使用两个依赖项:Newtonsoft.Json(用于处理 Slack API 和 CodeProject API 返回的 JSON)和 System.Net.WebSockets.Client(用于与 Slack 的实时消息 API 建立连接)。

代码组织在三个命名空间中:CodeProjectSlackIntegrationCodeProjectSlackIntegration.CodeProject(源文件位于 CodeProject 目录中)和 CodeProjectSlackIntegration.Slack(源文件位于 Slack 目录中)。CodeProject 子命名空间包含用于与 CodeProject API 通信和检查新内容的类。Slack 子命名空间包含用于与 Slack API 通信的类。然后 CodeProjectSlackIntegration 命名空间包含 Program 类以及负责集成 SlackCodeProject 子命名空间的类。

与 Slack API 通信

Api 类

使用 Slack API 需要收集机器人令牌,如“在 Slack 上设置应用和机器人”中所述。Slack 子命名空间中的 Api 类(请注意,这与 CodeProject 子命名空间中的 Api 类不同)有一个方法可以使用 Slack 的 postMessage API 在 Slack 频道中发布消息,以及一个方法用于设置实时消息(供我们稍后讨论的 Rtm 类使用)。为了进行授权,机器人令牌必须以 Bearer <token> 格式作为 Authorization 头传递。

class Api : IDisposable
{
    HttpClient client = new HttpClient();
    string botToken;

    public Api(string botToken)
    {
        this.botToken = botToken;
        client.BaseAddress = new Uri("https://slack.com/");
        client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", botToken);
    }

该类是 IDisposable,因为 HttpClient 也可以被释放,所以我们想在 Api 中添加一个 Dispose 方法来处理这个问题。

public void Dispose()
{
    client.Dispose();
}

Api 有一个 PostMessage 方法,它使用 Slack 的 chat.postMessage API 将消息发布到 Slack 工作区中的某个频道。为此,我们必须向适当的端点 POST 一个带有 channeltext 属性的 JSON 对象

public async Task PostMessage(string channel, string text)
{
    string json = JsonConvert.SerializeObject(new { channel, text });
    StringContent content = new StringContent(json, Encoding.UTF8, "application/json");
    await client.PostAsync("api/chat.postMessage", content);
}

请注意,我们从不需要指定要在哪个工作区中发布消息,只需指定频道即可。这是因为机器人令牌与特定的工作区相关联。

Api 类还有一个 RtmUrl 方法。此方法设置实时消息并返回您必须使用 Web 套接字连接的 URL。Web 套接字逻辑发生在 Rtm 类中,但 RTM 设置发生在此处

public async Task<(bool, string)> RtmUrl()
{
    string json = await client.GetStringAsync("api/rtm.connect?token=" + botToken);
    JObject response = JObject.Parse(json);
    bool ok = (bool)response["ok"];
    if (!ok)
    {
        return (false, (string)response["error"]);
    }
    else
    {
        return (true, (string)response["url"]);
    }
}

此方法返回一个 (bool, string),根据 API 响应中 ok 属性的值,它看起来像 (true, [url])(false, [error])JObject 类位于 Newtonsoft.Json.Linq 命名空间中,它允许我们通过对象的属性名称轻松访问值。

使用实时消息 (RTM) API

RTM API 使用 Web 套接字。这就是我们将使用 System.Net.WebSockets.Client 依赖项的原因。让我们首先看一下类定义和构造函数

class Rtm
{
    IMessageHandler messageHandler;
    CancellationToken cancellationToken;

    public Rtm(IMessageHandler messageHandler, CancellationToken cancellationToken)
    {
        this.messageHandler = messageHandler;
        this.cancellationToken = cancellationToken;
    }

取消令牌将用于告知 Rtm 何时必须停止监听消息。IMessageHandler 是一个具有一个方法 HandleMessage 的接口

interface IMessageHandler
{
    string HandleMessage(string text);
}

HandleMessage 接收消息作为输入,并应返回消息作为回复,或返回 null

Rtm 类有一个方法:DoWork。此方法将 Web 套接字 URL 作为参数,并持续监视新的 Slack 消息,并根据传入的 IMessageHandler 实现指定的方式进行响应。

public async Task DoWork(string url)
{

首先,该方法使用 ClientWebSocket(来自 System.Net.WebSockets.Client 包的 System.Net.WebSockets 命名空间)与 Web 套接字建立连接

ClientWebSocket cws = new ClientWebSocket();
await cws.ConnectAsync(new Uri(url), cancellationToken);
Console.WriteLine("Connected to web socket.");

int messageId = 1;

messageId 变量用于记录通过 RTM API 发送的消息数量,因为 API 要求为发送的每条消息传递该 ID。每发送一条消息,ID 就会增加一。

然后,在没有请求取消的情况下,此方法等待 RTM API 的新事件。我们为此使用 ClientWebSocket 上的 RecieveAsync 方法。此方法将接收到的事件存储在 ArraySegment<byte> 中,并返回 WebSocketReceiveResultReceiveAsync 可能不会立即一次性接收完整的 websocket 消息。有时需要多次接收。WebSocketReceiveResult 有一个 EndOfMessage 属性,它告诉我们 websocket 消息是否完整。我们将所有部分 websocket 消息写入 MemoryStream,一旦 EndOfMessagetrue,我们就会从中读取。

while (!cancellationToken.IsCancellationRequested)
{
    ArraySegment<byte> buffer = new ArraySegment<byte>(new byte[2048]);
    WebSocketReceiveResult result;
    using (var ms = new MemoryStream())
    {
        do
        {
            try
            {
                result = await cws.ReceiveAsync(buffer, cancellationToken);
            }
            catch (OperationCanceledException)
            {
                result = new WebSocketReceiveResult(0, WebSocketMessageType.Close, true);
                break;
            }
            ms.Write(buffer.Array, buffer.Offset, result.Count);
        } while (!result.EndOfMessage && !cancellationToken.IsCancellationRequested);

接收可能会被取消,然后它将抛出 OperationCanceledException。在 do...while 循环之后,我们首先检查操作是否被取消,然后再查看我们收到的消息。如果操作被取消,我们就会跳出循环。

        if (cancellationToken.IsCancellationRequested)
        {
            break;
        }

WebSocketReceiveResult 还告诉我们消息类型:BinaryTextClose。我们不会从 RTM API 接收 Binary,但可能会接收 Close。在这种情况下,我们关闭 ClientWebSocket 并跳出循环。

        if (result.MessageType == WebSocketMessageType.Close)
        {
            await cws.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
            return;
        }

如果类型不是 Close,我们知道它是 Text。我们使用 StreamReaderMemoryStream 读取文本并将其解析为 JSON。此 JSON 包含有关 Slack 事件的信息,它不一定是消息——例如,它也可能表示有人开始打字。但是我们只关心消息。如果 JSON 中存在 type 属性且其值为 message,则我们知道事件是消息。在这种情况下,我们调用 messageHandler 上的 HandleMessage 方法,并将该响应(如果存在)发送回 Slack。我们希望响应出现在与触发消息相同的频道中,我们可以通过检查 JSON 事件上的 channel 属性来知道这一点。要将响应发送到 Slack,我们必须通过 Web 套接字发送一个带有以下属性的 JSON 对象:id(此后我们递增的消息 ID)、type(值为 message)、channeltext

        ms.Seek(0, SeekOrigin.Begin);
        using (StreamReader reader = new StreamReader(ms, Encoding.UTF8))
        {
            JObject rtmEvent = JObject.Parse(await reader.ReadToEndAsync());
            if (rtmEvent.ContainsKey("type") && 
            (string)rtmEvent["type"] == "message")
            {
                string channel = (string)rtmEvent["channel"];
                string message = messageHandler.HandleMessage((string)rtmEvent["text"]);
                if (message != null)
                {
                    string json = JsonConvert.SerializeObject(new 
                    { id = messageId, type = "message", channel, text = message });
                    messageId++;
                    byte[] bytes = Encoding.UTF8.GetBytes(json);
                    await cws.SendAsync(new ArraySegment<byte>(bytes), 
                    WebSocketMessageType.Text, true, CancellationToken.None);
                }
            }
        }

处理完一条消息后,是时候回到循环的开头等待下一条消息了。在 while 循环之外,我们释放 ClientWebSocket。这发生在两种情况下:请求取消时,以及 Web 套接字关闭时。

    }
}

cws.Dispose();
Console.WriteLine("Web socket disposed.");

与 CodeProject API 通信

ClientSettings 和 Api

为了进行身份验证,我们需要客户端 ID 和客户端密钥,如前所述。在代码中,我们将这些值存储在 ClientSettings 类中。

namespace CodeProjectSlackIntegration.CodeProject
{
    class ClientSettings
    {
        public string ClientId
        {
            get;
            private set;
        }

        public string ClientSecret
        {
            get;
            private set;
        }

        public ClientSettings(string clientId, string clientSecret)
        {
            ClientId = clientId;
            ClientSecret = clientSecret;
        }
    }
}

CodeProject 子命名空间中的 Api 类包含向 CodeProject API 发出 Web 请求的方法。我们使用 HttpClient 类(来自 System.Net.Http)进行 HTTP 请求。类定义和构造函数如下所示

class Api : IDisposable
{
    HttpClient client;
    ClientSettings settings;

    public Api(ClientSettings settings)
    {
        this.settings = settings;
        client = new HttpClient();
        client.BaseAddress = new Uri("https://api.codeproject.com/");
        client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
    }

(请注意,这与 Slack 子命名空间中的 Api 类不同。)

这里,HttpClient 被构造。基地址设置为 https://api.codeproject.com/,以避免在执行请求的方法中重复此操作,并且 Accept 标头设置为 application/json,因为这是我们希望从 API 接收的。我们将该类派生自 IDisposable,因为 HttpClient 是可释放的,所以我们希望在该类上实现一个 Dispose 方法来释放 HttpClient

public void Dispose()
{
    client.Dispose();
}

在向 API 发送请求之前,我们必须首先进行身份验证。我们将客户端 ID 和客户端密钥传递给 /Token,它返回一个身份验证令牌。此令牌必须以 Bearer <token> 的形式放在未来请求的 Authorization 标头中。这发生在 Authenticate 方法中

public async Task Authenticate()
{
    string data = string.Format
    ("grant_type=client_credentials&client_id={0}&client_secret={1}",
        Uri.EscapeDataString(settings.ClientId), Uri.EscapeDataString(settings.ClientSecret));
    HttpResponseMessage response = await client.PostAsync("/Token", new StringContent(data));
    string json = await response.Content.ReadAsStringAsync();
    string token = (string)JObject.Parse(json)["access_token"];
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}

访问令牌存储在 API 响应的 access_token 属性中。

一旦通过身份验证,就可以访问其他 API 端点。Get 方法处理这个问题

async Task<JArray> Get(string url, List<string> tags, string tagParameter)
{
    string query;
    if (tags == null || tags.Count < 1 || tagParameter == null)
    {
        query = "";
    }
    else
    {
        query = string.Format("?{0}={1}", tagParameter, string.Join
                                          (",", tags.Select(Uri.EscapeDataString)));
    }
    try
    {
        string json = await client.GetStringAsync(url + query);
        return (JArray)JObject.Parse(json)["items"];
    }
    catch (HttpRequestException hre)
    {
        Console.WriteLine("API error on " + url + query);
        Console.WriteLine(hre.Message);
        return null;
    }
}

url 参数不应包含查询字符串。此应用程序使用查询字符串的唯一目的是指定文章和问题的标签。对于文章,标签通过 ?tags=tag1,tag2 指定,对于问题,标签通过 ?include=tag1,tag2 指定。标签列表作为方法参数 tags 传递,查询字符串参数名称(tagsinclude)作为 tagParameter 传递。对于论坛消息,没有标签,论坛 ID 包含在 URL 中,因此 tags 必须为 null,然后查询字符串将为空。API 返回一个带有 paginationitems 属性的 JSON 对象。我们对 items 感兴趣,它是一个数组,并将其作为 JArray 返回。

为方便起见,我们添加了 GetArticlesGetForumMessagesGetQuestions 方法,因此正确调用 Get 的责任在于 Api 类。

public async Task<JArray> GetArticles(List<string> tags)
{
    return await Get("v1/Articles", tags, "tags");
}

public async Task<JArray> GetForumMessages(int forumId)
{
    return await Get(string.Format("v1/Forum/{0}/Threads", forumId), null, null);
}

public async Task<JArray> GetQuestions(List<string> includeTags)
{
    return await Get("v1/Questions/new", includeTags, "include");
}

最后,Api 类公开了一个 static ParseApiDateTime 方法,该方法负责正确解析 API 响应中 MM/dd/yyyy HH:mm:ss 格式的时间戳

public static DateTime ParseApiDateTime(string s)
{
    return DateTime.ParseExact(s, "MM/dd/yyyy HH:mm:ss", CultureInfo.InvariantCulture);
}

监控新内容:帮助类

实际的内容监控发生在 ContentWatcher 类中。但是,此类依赖于我们将在本文中首先讨论的一些帮助类。第一个是 ContentSettings 类及其相关类。这些类保存了 ContentWatcher 将使用的设置,例如,是否应该监控文章?如果是,监控哪些标签?是否应该监控论坛?等等。

[Serializable]
class ContentSettings
{
    public ArticleSettings Articles { get; set; }
    public ForumSettings Forums { get; set; }
    public QaSettings Qa { get; set; }
}

[Serializable]
class ArticleSettings
{
    public bool Enabled { get; set; }
    public List<string> Tags { get; set; }
}

[Serializable]
class ForumSettings
{
    public bool Enabled { get; set; }
    public List<int> Forums { get; set; }
}

[Serializable]
class QaSettings
{
    public bool Enabled { get; set; }
    public List<string> Tags { get; set; }
}

这些类是 Serializable 的,因为它们将从 JSON 文件中读取和写入。

接下来,我们有 ContentType 枚举和 ContentSummary 类。ContentSummaryContentWatcher 在获取新内容时返回的内容,它存储内容类型、标题、摘要和网站链接。

enum ContentType
{
    Article,
    Message,
    Question
}
class ContentSummary
{
    public ContentType Type { get; private set; }
    public string Title { get; private set; }
    public string Summary { get; private set; }
    public string Link { get; private set; }

    public ContentSummary(ContentType type, JObject item)
    {
        Type = type;
        Title = (string)item["title"];
        Summary = (string)item["summary"];
        string link = (string)item["websiteLink"];
        if (!link.StartsWith("h"))
        {
            Link = "https:" + link;
        }
        else
        {
            Link = link;
        }
    }

构造函数从 JSON 对象中提取必要的值。无论内容类型如何,格式始终相同。link 属性可能是协议相对 URL,即不以 http://https:// 开头但以 // 开头的 URL(目的是,当此类 URL 从 HTML a 标签的 href 属性链接时,协议会保留——如果您来自 HTTP 站点,则链接到 HTTP 站点;如果您来自 HTTPS 站点,则链接到 HTTPS 站点)。Slack 不支持协议相对 URL 作为链接,因此如果是,我们会手动添加 https:

ContentSummary 类有一个 ToSlackMessage 方法,用于将摘要格式化以发布到 Slack

public string ToSlackMessage()
{
    if (Type == ContentType.Article)
    {
        return string.Format("New/updated article: <{0}|{1}>\r\n>>> {2}",
            Link,
            Title.Replace("&", "&").
            Replace("<", "<").Replace(">", "&gt;"),
            Summary.Replace("&", "&").
            Replace("<", "&lt;").Replace(">", "&gt;"));
    }
    else
    {
        return string.Format("New {0}: <{1}|{2}>",
            Type.ToString().ToLowerInvariant(),
            Link,
            Title.Replace("&", "&").
            Replace("<", "&lt;").Replace(">", "&gt;"));
    }
}

文章的格式与消息和问题的格式不同。对于消息和问题,摘要被省略。它对于这些类型不太有用,并且通常包含 Slack 不渲染的 HTML,使其看起来非常混乱。格式 <url|title> 告诉 Slack 创建一个链接,其中标题作为显示文本链接到 URL。对于标题和摘要,我们根据 Slack API 的要求,将 &<> 替换为其 HTML 实体。

监控新内容

现在我们已经完成了辅助类,我们可以看看实际工作发生的 ContentWatcher 类。调用此类的 FetchNewContent 方法将返回所有新内容——“新”在此上下文中意味着“自上次调用此方法以来新增的内容”。

好吧,我说我们已经完成了辅助类是撒谎了。ContentWatcher 有一个嵌套的 Result

class ContentWatcher
{
    public class Result
    {
        public bool AllSuccessful { get; private set; }
        public List<ContentSummary> Content { get; private set; }

        public Result(bool allSuccessful, List<ContentSummary> content)
        {
            AllSuccessful = allSuccessful;
            Content = content;
        }
    }

我已经提到了 FetchNewContent - Result 是此方法将返回的实例类型。这样,调用者就可以知道所有 API 请求(文章、问题、每个论坛)是否都成功了。

接下来在类中,我们有一些成员声明和构造函数

Api api;
ContentSettings settings;

string latestArticleId = null;
string latestQuestionId = null;
Dictionary<int, string> latestMessageIds = new Dictionary<int, string>();

public ContentWatcher(Api api, ContentSettings settings)
{
    this.api = api;
    this.settings = settings;
}

由于 FetchNewContent 必须只返回自上次调用以来的新内容,因此必须存储该调用时的最新 ID。对于文章和问题,我们可以将其存储在 string 中。对于消息,我们不能,因为最新 ID 取决于我们正在查看的论坛。这就是为什么我们在那里使用字典:可以为每个论坛存储最新 ID。

FetchNewContent 分为三个部分:FetchNewArticlesFetchNewQuestionsFetchNewMessagesFetchNewArticles 将请求最新(新/更新)文章列表,遍历它们并将它们添加到结果列表(将返回)中,直到文章 ID 等于上次调用时存储在 latestArticleId 中的最新 ID。最后,latestArticleId 会更新。

async Task<Result> FetchNewArticles()
{
    List<ContentSummary> result = new List<ContentSummary>();
    JArray articles = await api.GetArticles(settings.Articles.Tags);
    if (articles == null)
    {
        return new Result(false, result);
    }

    foreach (JObject article in articles)
    {
        string id = (string)article["id"];
        if (id == latestArticleId)
        {
            break;
        }
        result.Insert(0, new ContentSummary(ContentType.Article, article));
    }

    if (articles.Count > 0)
    {
        latestArticleId = (string)articles[0]["id"];
    }
    return new Result(true, result);
}

GetArticles 的结果为 null 时,我们返回一个 Result,其中 allSuccessfulfalse,因为它表示请求失败——如果您回顾 Api.Get 方法,nullcatch 块中返回(即,当请求失败时)。

FetchNewQuestions 遵循与 FetchNewArticles 完全相同的逻辑(但这里仅针对新问题,不包括新/更新的问题)

async Task<Result> FetchNewQuestions()
{
    List<ContentSummary> result = new List<ContentSummary>();

    JArray questions = await api.GetQuestions(settings.Qa.Tags);
    if (questions == null)
    {
        return new Result(false, result);
    }

    foreach (JObject question in questions)
    {
        string id = (string)question["id"];
        if (id == latestQuestionId)
        {
            break;
        }
        result.Insert(0, new ContentSummary(ContentType.Question, question));
    }

    if (questions.Count > 0)
    {
        latestQuestionId = (string)questions[0]["id"];
    }

    return new Result(true, result);
}

FetchNewMessages 工作方式类似,但有两个额外的困难

  1. 我们必须遍历所有要关注的论坛列表。
  2. 有些论坛有置顶消息,这些消息总是置于第一页的顶部。它们也总是 API 返回的第一个项目。FetchNewMessages 必须完全忽略它们,并且由于 API 没有告诉我们一条消息是否是置顶消息,我们使用这种变通方法:如果一条消息的时间戳早于 API 响应中*最后*一条消息(不包括置顶消息)的时间戳(这将是最早的消息),那么我们知道这条消息是置顶消息并且必须被忽略。
async Task<Result> FetchNewMessages()
{
    List<ContentSummary> result = new List<ContentSummary>();
    bool allSuccess = true;
    foreach (int forumId in settings.Forums.Forums)
    {
        JArray messages = await api.GetForumMessages(forumId);
        if (messages != null && messages.Count > 0)
        {
            DateTime oldest = Api.ParseApiDateTime((string)messages.Last()["createdDate"]);
            var noSticky = messages.Where(x => Api.ParseApiDateTime((string)x["createdDate"]) >= oldest);
            foreach (JObject message in noSticky)
            {
                string id = (string)message["id"];
                Console.WriteLine(id);
                if (latestMessageIds.ContainsKey(forumId) && id == latestMessageIds[forumId])
                {
                    break;
                }
                result.Insert(0, new ContentSummary(ContentType.Message, message));
            }

            latestMessageIds[forumId] = (string)noSticky.First()["id"];
        }
        else
        {
            allSuccess = false;
        }
    }
    return new Result(allSuccess, result);
}

在所有这些方法中,我们都使用了 result.Insert(0, ...) 而不是 result.Add(...)。这确保了最旧的项目首先出现在列表中,因此它们将以按时间顺序正确的顺序发布到 Slack 工作区。

最后,FetchNewContent 方法。它检查 settings 中要获取的内容类型,并相应地执行。

public async Task<Result> FetchNewContent()
{
    bool allSuccess = true;
    List<ContentSummary> result = new List<ContentSummary>();
    if (settings.Articles.Enabled)
    {
        Result newArticles = await FetchNewArticles();
        allSuccess &= newArticles.AllSuccessful;
        result.AddRange(newArticles.Content);
    }
    if (settings.Qa.Enabled)
    {
        Result newQuestions = await FetchNewQuestions();
        allSuccess &= newQuestions.AllSuccessful;
        result.AddRange(newQuestions.Content);
    }
    if (settings.Forums.Enabled)
    {
        Result newMessages = await FetchNewMessages();
        allSuccess &= newMessages.AllSuccessful;
        result.AddRange(newMessages.Content);
    }
    return new Result(allSuccess, result);
}

集成

Settings 类

我们已经看到了 ContentSettings,但该类只包含 ContentWatcher 的设置。集成还需要另外两个设置:要发布到的 Slack 频道,以及内容观察者在获取新内容之前需要等待的时间间隔。该类还有一个 LoadFromFile static 方法和 SaveToFile 方法,用于将设置加载/保存为 JSON 文件。

[Serializable]
class Settings
{
    public CodeProject.ContentSettings Content { get; set; }
    public int RefreshIntervalSeconds { get; set; }
    public string Channel { get; set; }

    public static Settings LoadFromFile(string path)
    {
        return JsonConvert.DeserializeObject<Settings>(File.ReadAllText(path));
    }

    public void SaveToFile(string path)
    {
        File.WriteAllText(path, JsonConvert.SerializeObject(this));
    }

当您第一次运行应用程序时,您还没有设置文件。然后应用程序将使用默认设置。这些是什么?它们在 Settings 上的 static Default 属性中定义。

public static Settings Default
{
    get
    {
        Settings settings = new Settings();
        settings.RefreshIntervalSeconds = 120;
        settings.Channel = "#general";
        settings.Content = new CodeProject.ContentSettings();
        settings.Content.Articles = new CodeProject.ArticleSettings();
        settings.Content.Forums = new CodeProject.ForumSettings();
        settings.Content.Qa = new CodeProject.QaSettings();
        settings.Content.Articles.Enabled = false;
        settings.Content.Articles.Tags = new List<string>();
        settings.Content.Forums.Enabled = false;
        settings.Content.Forums.Forums = new List<int>();
        settings.Content.Qa.Enabled = false;
        settings.Content.Qa.Tags = new List<string>();
        return settings;
    }
}

默认情况下,所有内容类型都已禁用。未设置文章标签、问题标签和论坛 ID。新内容每 2 分钟获取一次,并发布到 Slack 工作区的 #general 频道中。

MessageHandler 类

我们现在有单独的代码来处理 Slack API 和 CodeProject API。现在,是时候编写集成两者的代码了。首先,我们看一下 MessageHandler 类,它是 IMessageHandler 接口的实现。这个类,特别是 HandleMessage 方法,处理机器人用户加入的 Slack 频道中发布的消息。消息处理程序将把所有格式为 !codeproject command argument1 argument2 ... 的消息视为命令。Slack 自己提供了“斜杠命令”,但此应用程序不使用这些命令,因为这些斜杠命令会向您指定的服务器发送 HTTP 请求,因此这将需要有一个 Web 服务器或允许外部传入连接到您的计算机。定义自己的命令格式可以避免此要求,您只需要保持应用程序运行。

以下是已实现的命令列表

  • help - 显示命令帮助
  • overview - 查看当前设置
  • interval <seconds> - 设置检查新内容的时间间隔
  • channel <channel> - 设置发布新内容的频道。示例:!codeproject channel general(省略 #)
  • articles/forums/questions enable/disable - 启用或禁用文章、论坛或问题的内容检查。示例:!codeproject articles enable
  • articles/questions tags add/remove <tag> - 为文章或问题添加或删除要监听的一个标签。示例:!codeproject questions tags add c#
  • (当未设置标签时,包括所有标签。)
  • articles/questions tags set tag1,tag2,tag3,... - 为文章或问题设置所有要监听的标签。示例:!codeproject questions tags add set c,c++
  • articles/questions tags clear - 清除所有设置的标签
  • qaquestions 在所有地方的有效同义词
  • forums add/remove <forumId> - 添加或删除要监视新消息的论坛 ID。示例(内部新闻):!codeproject forums add 1658735
  • 论坛 ID 可以在论坛 URL 中找到。在某些情况下,它不存在。然后点击“新建讨论”,查看查询字符串中的 fid 参数。
  • 当未设置论坛 ID 时,不会检索任何消息。
  • forums clear - 清除要监视的论坛列表
  • forums set id1,id2,id3,... - 一次性设置所有要监听的论坛 ID
  • stop - 停止应用程序

MessageHandler 的类定义和构造函数如下所示

class MessageHandler : Slack.IMessageHandler
{
    ConcurrentQueue<Settings> settingsUpdateQueue;
    string settingsFilepath;
    CancellationTokenSource cancellationToken;

    public MessageHandler(ConcurrentQueue<Settings> settingsUpdateQueue, 
                          string settingsFilepath, CancellationTokenSource cancellationToken)
    {
        this.settingsUpdateQueue = settingsUpdateQueue;
        this.settingsFilepath = settingsFilepath;
        this.cancellationToken = cancellationToken;
    }

当调用设置更新命令时,ConcurrentQueue 将用于告知 ContentWatcher 控制代码设置已更新。此处的取消令牌不是告知 MessageHandler 必须取消操作,而是 MessageHandler 将使用它在调用 stop 命令时请求取消其他任务。命令处理发生在 HandleMessage 方法中,该方法接受一个 string 作为参数(Slack 消息),作为 IMessageHandler.HandleMessage 的实现。

public string HandleMessage(string text)
{

在此方法中,我们首先检查消息是否是命令——即,第一个“单词”是否是 !codeproject。如果不是,我们返回 null,因为我们不想在 Slack 上回复任何内容。

string[] words = text.Trim().Split(new char[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
if (words.Length < 2 || words[0] != "!codeproject")
{
    return null;
}

如果“单词”的数量少于两个,我们也可以忽略该消息,因为没有实际命令的 !codeproject 什么也不做。接下来,我们从设置文件中加载当前设置。

Settings settings = Settings.LoadFromFile(settingsFilepath);
string message = null;
bool update = false;

并非所有命令都会对设置文件进行任何更改。如果调用了设置更新命令,update 标志将设置为 true,因此在调用命令后,我们知道必须使用 settingsUpdateQueue 来传播设置更改的消息。message 将设置为要发布到 Slack 的回复。如果它仍然是 null,原因是命令无法识别或无效。接下来在方法中,我们有一个(嵌套的)switch 块,它作用于有效命令,对于设置更新命令执行输入验证,更新 settings 对象并将 update 标志设置为 true。对于 stop 命令,它调用取消令牌上的 Cancel 方法。

switch (words[1])
{
    case "overview":
        StringBuilder overviewBuilder = new StringBuilder();
        overviewBuilder.AppendLine("Settings overview:");
        overviewBuilder.AppendFormat("Articles enabled: {0}. Tags: {1}\r\n", 
        settings.Content.Articles.Enabled, string.Join(", ", settings.Content.Articles.Tags));
        overviewBuilder.AppendFormat("Messages enabled: {0}. Forums: {1}\r\n", 
        settings.Content.Forums.Enabled, string.Join(", ", settings.Content.Forums.Forums));
        overviewBuilder.AppendFormat("Questions enabled: {0}. Tags: {1}\r\n", 
        settings.Content.Qa.Enabled, string.Join(", ", settings.Content.Qa.Tags));
        overviewBuilder.AppendFormat("Refresh interval: {0} seconds\r\n", 
        settings.RefreshIntervalSeconds);
        overviewBuilder.AppendFormat("Posting channel: {0}", settings.Channel);
        message = overviewBuilder.ToString();
        break;
    case "interval":
        if (words.Length < 3) break;
        if (int.TryParse(words[2], out int newInterval))
        {
            settings.RefreshIntervalSeconds = newInterval;
            message = "Interval set.";
            update = true;
        }
        break;
    case "channel":
        if (words.Length < 3) break;
        if (Regex.IsMatch(words[2], "^[a-zA-Z0-9_-]+$"))
        {
            settings.Channel = "#" + words[2];
            message = "New channel set.";
            update = true;
        }
        break;
    case "articles":
        if (words.Length < 3) break;
        switch (words[2])
        {
            case "enable":
                settings.Content.Articles.Enabled = true;
                message = "Article notifications enabled.";
                update = true;
                break;
            case "disable":
                settings.Content.Articles.Enabled = false;
                message = "Article notifications disabled.";
                update = true;
                break;
            case "tags":
                if (words.Length < 4) break;
                switch (words[3])
                {
                    case "clear":
                        settings.Content.Articles.Tags.Clear();
                        message = "Article tags cleared.";
                        update = true;
                        break;
                    case "set":
                        if (words.Length < 5) break;
                        settings.Content.Articles.Tags = new List<string>(words[4].Split(','));
                        message = "Article tags set.";
                        update = true;
                        break;
                    case "add":
                        if (words.Length < 5) break;
                        settings.Content.Articles.Tags.Add(words[4]);
                        message = "Article tag added.";
                        update = true;
                        break;
                    case "remove":
                        if (words.Length < 5) break;
                        settings.Content.Articles.Tags.Remove(words[4]);
                        message = "Article tag removed, if it was in the list.";
                        update = true;
                        break;
                }
                break;
        }
        break;
    case "forums":
        if (words.Length < 3) break;
        switch (words[2])
        {
            case "enable":
                settings.Content.Forums.Enabled = true;
                message = "Forum notifications enabled.";
                update = true;
                break;
            case "disable":
                settings.Content.Forums.Enabled = false;
                message = "Forum notifications disabled.";
                update = true;
                break;
            case "clear":
                settings.Content.Forums.Forums.Clear();
                message = "Forum list cleared.";
                update = true;
                break;
            case "add":
                if (words.Length < 4) break;
                if (int.TryParse(words[3], out int newForum))
                {
                    settings.Content.Forums.Forums.Add(newForum);
                    message = "Forum added.";
                    update = true;
                }
                break;
            case "remove":
                if (words.Length < 4) break;
                if (int.TryParse(words[3], out int forumToRemove))
                {
                    settings.Content.Forums.Forums.Remove(forumToRemove);
                    message = "Forum removed, if it was in the list.";
                    update = true;
                }
                break;
            case "set":
                if (words.Length < 4 || !Regex.IsMatch(words[3], "^(\\d,?)+$")) break;
                settings.Content.Forums.Forums = new List<int>(words[3].Split(new char[] 
                { ',' }, StringSplitOptions.RemoveEmptyEntries).Select(int.Parse));
                message = "Forums set.";
                update = true;
                break;
        }
        break;
    case "questions":
    case "qa":
        if (words.Length < 3) break;
        switch (words[2])
        {
            case "enable":
                settings.Content.Qa.Enabled = true;
                message = "Question notifications enabled.";
                update = true;
                break;
            case "disable":
                settings.Content.Qa.Enabled = false;
                message = "Question notifications disabled.";
                update = true;
                break;
            case "tags":
                if (words.Length < 4) break;
                switch (words[3])
                {
                    case "clear":
                        settings.Content.Qa.Tags.Clear();
                        message = "Question tags cleared.";
                        update = true;
                        break;
                    case "set":
                        if (words.Length < 5) break;
                        settings.Content.Qa.Tags = new List<string>(words[4].Split(','));
                        message = "Question tags set.";
                        update = true;
                        break;
                    case "add":
                        if (words.Length < 5) break;
                        settings.Content.Qa.Tags.Add(words[4]);
                        message = "Question tag added.";
                        update = true;
                        break;
                    case "remove":
                        if (words.Length < 5) break;
                        settings.Content.Qa.Tags.Remove(words[4]);
                        message = "Question tag removed, if it was in the list";
                        update = true;
                        break;
                }
                break;
        }
        break;
    case "help":
        StringBuilder help = new StringBuilder();
        help.AppendLine("To execute a command, do `!codeproject command`. Available commands:");
        help.AppendLine("`help` - shows command help.");
        help.AppendLine("`overview` - view current settings.");
        help.AppendLine("`interval &lt;seconds&gt;` 
                                 - set interval for checking for new content.");
        help.AppendLine("`channel &lt;channel&gt;` 
                                 - set channel to post new content in.");
        help.AppendLine("`articles/forums/questions enable/disable` 
          - enables or disables content checking for articles, forums, or questions. 
            Example: `!codeproject forums enable`");
        help.AppendLine("`articles/questions tags add/remove &lt;tag&gt;` 
          - adds or removes one tag to listen for, for articles or questions. 
            Example: `!codeproject questions tags add c#`");
        help.AppendLine("When no tags are set, all tags are included.");
        help.AppendLine("`articles/questions tags set tag1,tag2,tag3` 
              - sets all tags to listen for, for articles or questions, at once. 
                 Example: `!codeproject articles tags set c#,.net`");
        help.AppendLine("`articles/questions tags clear` - clears all set tags.");
        help.AppendLine("`qa` is a valid synonym for `questions` everywhere.");
        help.AppendLine("`forums add/remove &lt;forumId&gt;` 
              - adds or removes a forum ID to watch new messages for. 
                Example (The Insider News): `!codeproject forums add 1658735`");
        help.AppendLine("The forum ID can be found in the forum URL. 
         In some cases it's not there, click 'New Discussion' and see the `fid` parameter in the query string.");
        help.AppendLine("When no forum IDs are set, no messages will be retrieved.");
        help.AppendLine("`forums clear` - clears list of forums to watch.");
        help.AppendLine("`forums set id1,id2,id3` - sets all forum IDs to listen to at once.");
        help.AppendLine("`stop` - stops the application.");
        message = help.ToString();
        break;
    case "stop":
        cancellationToken.Cancel();
        return null;
}

这是一个很大的代码块,但没有发生复杂的事情。我们只需要处理所有可能的命令,对于大多数命令,只需检查输入是否有效并相应地调整 settings 的属性即可。

如果 update 标志设置为 true,我们必须将更新后的设置保存到正确的文件,并将此更新推送到 settingsUpdateQueue。然后我们返回 message,但如果此时 message 仍然是 null,则命令无效,因此消息是 Invalid command.

if (update)
{
    settings.SaveToFile(settingsFilepath);
    settingsUpdateQueue.Enqueue(settings);
}

return message ?? "Invalid command.";

Integration 类

我们还没有编写任何控制 ContentWatcher 或 Slack API 的代码。这就是我们在 Integration 类中要做的。让我们首先看一下类定义和构造函数

class Integration
{
    Slack.Api slack;
    CodeProject.Api codeproject;
    Settings settings;
    CodeProject.ContentWatcher watcher;
    ConcurrentQueue<Settings> settingsUpdateQueue;
    string settingsFilepath;
    CancellationTokenSource cancellationToken;

    public Integration(string slackBotToken, CodeProject.ClientSettings cpSettings, 
                       Settings settings, string settingsFilepath)
    {
        slack = new Slack.Api(slackBotToken);

        codeproject = new CodeProject.Api(cpSettings);
        this.settings = settings;
        this.settingsFilepath = settingsFilepath;

        cancellationToken = new CancellationTokenSource();

        settingsUpdateQueue = new ConcurrentQueue<Settings>();

        watcher = new CodeProject.ContentWatcher(codeproject, settings.Content);
    }

构造函数接收 Slack 机器人令牌、一个 ClientSettings 实例(用于客户端 ID 和客户端密钥)、一个 Settings 实例以及一个包含设置文件路径的 stringConcurrentQueue 稍后将传递给 MessageHandler 的实例;此类是我们在该部分中提到的 ContentWatcher 控制代码。取消令牌也将传递给 MessageHandler,并将用于取消此类中正在进行的操作。

处理 Slack 连接的方法 WatchSlack 如下所示

async Task WatchSlack()
{
    while (!cancellationToken.IsCancellationRequested) // loop to re-connect on unexpected closures
    {
        var (success, url_or_error) = await slack.RtmUrl();

        if (!success)
        {
            throw new Exception("Could not connect to Slack RTM: " + url_or_error);
        }

        Slack.Rtm rtm = new Slack.Rtm(new MessageHandler
        (settingsUpdateQueue, settingsFilepath, cancellationToken), cancellationToken.Token);
        await rtm.DoWork(url_or_error);
    }
}

DoWork 将一直运行,直到请求取消,或者直到 WebSocket 意外关闭。在前一种情况下,循环退出。在后一种情况下,循环将确保应用程序重新连接到 Slack。

另一个任务,WatchCodeProject 方法,控制 ContentWatcher

async Task WatchCodeProject()
{
    await codeproject.Authenticate();
    await watcher.FetchNewContent(); // discard first batch, everything counts as new content
    while (!cancellationToken.IsCancellationRequested)
    {
        Settings settingsUpdate = null;
        while (settingsUpdateQueue.Count > 0)
        {
            settingsUpdateQueue.TryDequeue(out settingsUpdate);
        }

        if (settingsUpdate != null)
        {
            settings = settingsUpdate;
            watcher = new CodeProject.ContentWatcher(codeproject, settings.Content);
            await watcher.FetchNewContent();
        }

        CodeProject.ContentWatcher.Result fetched = await watcher.FetchNewContent();
        if (!fetched.AllSuccessful)
        {
            await slack.PostMessage(settings.Channel, 
                     "Error: not all CodeProject API requests were successful.");
        }

        foreach (CodeProject.ContentSummary content in fetched.Content)
        {
            await slack.PostMessage(settings.Channel, content.ToSlackMessage());
        }

        try
        {
            await Task.Delay(settings.RefreshIntervalSeconds * 1000, cancellationToken.Token);
        }
        catch (TaskCanceledException) { }
    }

    codeproject.Dispose();
    slack.Dispose();
    Console.WriteLine("API clients disposed.");
}

首先,我们执行必要的 API 身份验证。然后,我们第一次调用 FetchNewContent 并忽略结果,因为此时所有内容都将是新内容,我们不想用它垃圾邮件 Slack 频道。在循环内部,我们检查是否有设置更新。如果有,我们替换整个内容观察者并再次丢弃第一批。我们替换整个观察者是因为存储最新 ID 的字段可能根本不适用于我们的新设置,所以我们最好从头开始使用一个全新的内容观察者。然后我们检查新内容并将所有新项目发布到适当的 Slack 频道。如果并非所有请求都成功,我们也会向 Slack 发布通知——有关失败请求的更多详细信息可以在应用程序的控制台上找到。最后,我们使用 Task.Delay 等待,然后检查新内容。如果请求取消,我们退出循环并释放 API 客户端。

我们现在有两个独立的 Task,但我们仍然必须确保它们运行。我们可以使用 Task.WaitAll 来运行它们,但如果其中一个抛出异常,另一个将继续运行,并且 WaitAll 不会返回(并且异常也不会显示在控制台上)。对于此应用程序,我们宁愿在发生异常时立即退出,以便我们能够立即在控制台上看到哪里出了问题。我们不使用 WaitAll,而是使用 WaitAny,如果我们看到一个任务无故障完成,我们仍然等待另一个任务完成——这种情况只会在 stop 命令请求取消时发生,然后我们知道两个任务都即将完成。

public void Start()
{
    Task cpTask = WatchCodeProject();
    Task slackTask = WatchSlack();
    Task[] tasks = new Task[] { cpTask, slackTask };
    int completed = Task.WaitAny(tasks);
    if (tasks[completed].IsFaulted)
    {
        throw tasks[completed].Exception;
    }
    else
    {
        tasks[1 - completed].Wait();
    }
}

WaitAny 返回完成任务的索引。我们只有两个任务,所以可以使用 1 - completed 获取另一个任务的索引。

Main 方法

当您运行应用程序时,您必须传递 Slack 机器人令牌、CodeProject API 的客户端 ID/密钥,以及设置将从中读取和保存到的文件路径。您有两种选择来传递这些值。您可以不带命令行参数运行应用程序,然后应用程序会提示您输入这些值。或者您可以带一个命令行参数运行应用程序,该参数指向一个 JSON 文件,其中这些值以以下格式存储

{
    "slack": "Slack bot token",
    "clientId": "CodeProject Client ID",
    "clientSecret": "CodeProject Client Secret",
    "settings": "settings file path"
}

Main 方法中,我们有

static void Main(string[] args)
{
    string slackBot;
    string clientId;
    string clientSecret;
    string settingsPath;
    if (args.Length == 0)
    {
        Console.WriteLine("Slack bot token:");
        slackBot = Console.ReadLine().Trim();
        Console.WriteLine("CodeProject Client ID:");
        clientId = Console.ReadLine().Trim();
        Console.WriteLine("CodeProject Client Secret:");
        clientSecret = Console.ReadLine().Trim();
        Console.WriteLine("Settings file path (will be created if non-existing):");
        settingsPath = Console.ReadLine().Trim();
        Console.Clear();
    }
    else
    {
        JObject startup = JObject.Parse(File.ReadAllText(args[0]));
        slackBot = (string)startup["slack"];
        clientId = (string)startup["clientId"];
        clientSecret = (string)startup["clientSecret"];
        settingsPath = (string)startup["settings"];
    }

然后,如果文件存在,我们从给定文件中读取设置。如果不存在,我们采用默认设置并将其存储在该文件中。

    Settings settings;
    if (File.Exists(settingsPath))
    {
        settings = Settings.LoadFromFile(settingsPath);
    }
    else
    {
        settings = Settings.Default;
        settings.SaveToFile(settingsPath);
    }

最后,我们创建一个 Integration 实例并让它完成它的工作。

    Integration integration = new Integration
      (slackBot, new CodeProject.ClientSettings(clientId, clientSecret), settings, settingsPath);
    integration.Start();
}

大功告成!

© . All rights reserved.