Twitter APIが使えないので、RSS経由でDiscordにニュースを送る

この記事は [ここのえ Advent Calendar 2023](https://qiita.com/advent-calendar/2023/99no_exit) Day 9 の記事です。

# 俺たちの知っているTwitterはもうないぜ

20XX年、某ーロン・マスクによって我々の愛するTwitterの青い大地は粛清の炎に包まれ、後に残ったのは **焦土作戦によって真っ黒に染まったアイコンと「X」の一文字** でした(本当か？)。

Twitterをインフラかの如く扱っていた私みたいな人類にとっては、過酷なインターネットになってしまったため、阿鼻叫喚の嵐です。

特に困ったのが情報系サイトの新着情報を取得する方法で、Tweetdeckが使えないので本当に困っていました。かといってFeedlyとかは使ったことないですし、私みたいなズボラ人間だと開くのが面倒になってそのうち使わなくなりそうです。

# Discordに全部飛ばしてしまえ

最適解を考えると、普段から頻繁に使っている、かついい感じに表示ができるサービスが望ましいです。

そこで白羽の矢が立ったのが、Twitterの次に愛する𝑮𝙧𝒆𝙖𝒕 𝑺𝙚𝒓𝙫𝒊𝙘𝒆、**Discord** くんです。
頻繁に使うサービスである上、メッセージの整形などもでき、複数デバイスで既読状況も共有してくれるので割と最適なサービスです。

幸いDiscordにはbotを開発しやすいよう、SDK等が整えられています。Nodeの環境なら [**Discord.js**](https://discord.js.org/) を使う事で、簡単にAPIを叩くことができます。

情報源にしてもTwitter APIも有料化されてしまったので、一庶民が記事を集約するには正直使えたものではありません。なので古き良き文化、**RSS**を活用します。

# 実装

実装時のコアな部分について解説します。
詳細はGithubのリポジトリを確認してください。

https://github.com/nonuplet/discord-rigate

## rss.json

対象のサイトに関するRSSの情報をまとめておくためのJSONファイルです。
DiscordのチャンネルIDをパラメータとして持ち、送り先を指定します。

また好きなサイトだけど興味のないワードを弾きたい (`セール情報`とか`NFT`とか) ので、`bannedWords` に含まれたタイトルは弾く工夫をしています。

```json
[
  {
    "title": "PC NEWS",
    "rss": "https://example.com",
    "version": 1,
    "channelId": "123456789",
    "bannedWords": [
      "foo",
      "bar"
    ]
  }
]
```

## RSSの取得

フィードの取得を実際に行っています。
[**rss-parser**](https://www.npmjs.com/package/rss-parser) を使うと簡単にRSSをパースして、取り扱うことができます。

```ts 
  async #getFeeds(): Promise<RssSource[]> {
    const result = [] as RssSource[];
    const sources = JSON.parse(
      fs.readFileSync("rss.json", "utf8"),
    ) as RssSource[];

    // Get RSS
    for (const source of sources) {
      const data = await this.parser.parseURL(source.rss);
      if (source.version === 1) {
        source.feeds = this.#parseRss1(data);
      } else {
        source.feeds = this.#parseRss2(data);
      }
      result.push(source);
    }
    return result;
  }
```

RSSのバージョンによって`title`や`link`の取得する際に使うフィールドが異なるので、パース時の挙動を明示的に分けています。

今回は`rss.json`の値を基に決め打ちにしてしまいましたが、もしかしたら `rss-parser` にversionを定義するフィールドがあるかも？

## 不要データを飛ばす

パース後にフィードのチェックを行います。
cronで毎時10:00に走らせているので、24時間以内の投稿でない場合は無視するようにしています。

加えてタイトルに `bannedWords` が含まれていた場合、これも無視するように実装しています。

```ts
#checkFeed(
  feed: Feed,
  now: Date,
  bannedWords: string[] | undefined,
): boolean {
  // Check pubDate
  if ((now.getTime() - feed.date.getTime()) / (1000 * 60 * 60 * 24) > 1) {
    return false;
  }

  // Check banned words
  if (bannedWords === undefined) return true;
  for (const banned of bannedWords) {
    if (feed.title.includes(banned)) {
      return false;
    }
  }
  return true;
}
```

## メッセージの整形

Discordのメッセージは、ある程度Markdown記法に対応しています。[^1]
これを使ってメッセージが見やすいように整形していきます。

またDiscord.jsには、`hyperlink()` のようなメッセージの整形が簡単にできるメソッドも用意されているので、活用していきます。

```ts
for (const website of websites) {
  let message = "";
  if (website.feeds === undefined) continue;

  message += "# " + website.title + "\n";
  message += "### Updated: " + nowString + "\n";
  let feedCount = 0;

  for (const feed of website.feeds) {
    if (!this.#checkFeed(feed, now, website.bannedWords)) continue;

    const line = "- " + hyperlink(feed.title, feed.link) + "\n";
    if (message.length + line.length < 2000 && feedCount < 6) {
      message += line;
      feedCount++;
    } else {
      await rigate.sendMessage(website.channelId, message);
      message = line;
      feedCount = 1;
    }
  }
  if (feedCount > 0) {
    await rigate.sendMessage(website.channelId, message);
  }
}
```

ここで注意なのですが、**Discordのメッセージの最大長は2000文字まで** と決まっています。

たいていRSSで参照するのはブログや情報サイトの記事だったりするのでURL長が非常に長く、**タイトル+URLで4～5記事あれば簡単に溢れてしまいます**。

もう一つ罠があり、**Discordのメッセージで表示できるOGPのカードは5つまでです**。それ以上URLが含まれていた場合、無視されてしまい表示されません。

以上2点の問題を避けるために、今回の実装では

```ts
if (message.length + line.length < 2000 && feedCount < 6) {
```
として、メッセージを分割するような処理を行っています。

## メッセージ送信

Discord.jsを使って特定のチャンネルにメッセージを送信するのには、少しだけコツがあります。

基本的にはbotは何かしらアクションがあった(`/`コマンドで呼び出された、リプライを送られた、など)時に、それに対応して返すのが基本です。

ただし今回の用途ではそういったリクエストが存在しないため、そこから送信先のチャンネルの参照を取ることができません。

そこで `channel.resolve(channelId)` を使用することで、チャンネルの参照を手動で取ってきて送信を行っています。

```ts
async sendMessage(channelId: string, message: string): Promise<void> {
  const channel = this.#client.channels.resolve(channelId) as TextChannel;
  await channel.send(message);
}

// this.#clientはDiscord.jsのClientクラスです
```

# 終わりに

平成の時代にRSSなんて必要あるのか？と思っていた時期がありましたが、プラットフォーム暴君によってすぐ環境がめちゃくちゃにされる令和の時代では、やっぱりオープンなプロトコルの暖かさが身に沁みますね……

[^1]: [マークダウン記法101 （チャットフォーマット: Bold, Italic, 下線）](https://support.discord.com/hc/ja/articles/210298617-%E3%83%9E%E3%83%BC%E3%82%AF%E3%83%80%E3%82%A6%E3%83%B3%E8%A8%98%E6%B3%95101-%E3%83%81%E3%83%A3%E3%83%83%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%83%9E%E3%83%83%E3%83%88-Bold-Italic-%E4%B8%8B%E7%B7%9A-)