太陽がまぶしかったから

C'etait a cause du soleil.

ニュースレターメールをSlackに転送して任意たん風AIアシスタント掛け合いで要約してもらう

メール未読地獄と“後で読まない問題”

新聞社、雑誌社、サービス提供企業などのニュースレターメールをいくつも購読しているのだけど、メールボックスの未読アイコンが増えていくだけになっている現状があった。じゃあ、購読解除すればとも思うが、XのリンクやSlackに来ているRSSフィードは読めているのだから情報が欲しくないわけでも、時間がないわけでもない。だけどもHTMLメールを個別に開いて読んでアーカイブしていく作業がだるい。

そもそもニュースレターは「見逃しても見流さなくても良い」ものだ。そこでニュースレターメールについてはSlackに転送して、メール内容をAIに処理してもらうことでSlackのフィード消化の導線に載せることにした。

今回は羅列されたニュースを読みやすくするために日本語での会話調の解説文に整形した上で、リンク集を作る形とした。この文章自体がSlackに投稿されることで気になったリンク先の記事をシェアしたり、ボタンによる会話の深掘り機能などもつけやすい。海外メールも翻訳される。

メールをSlackに転送してAIに要約させる方法

ソースコード

シーケンス図

sequenceDiagram
    participant Gmail
    participant Slack
    participant PubBot
    participant SubBot
    participant OpenAI

    Mail ->> Slack: 転送フィルタによりメール転送
    Slack->>Slack: メールをメール用チャンネルに投稿(ファイル添付扱い)
    Slack->> PubBot: メール用チャンネルの file_shared イベント通知
    PubBot->> Slack: ack
    PubBot ->> SubBot : 処理対象メッセージ・ファイル情報等を topic に詰め込んで通知
    SubBot ->> SubBot : HTMLメールダウンロード・デコード
    SubBot ->> SubBot : HTMLメールのスクレイピング
    SubBot ->>OpenAI: メール変換プロンプト
    OpenAI->> SubBot:  変換テキスト
    SubBot ->>Slack: chat.updateMessageで要約付き投稿を表示

実装方針としては上記のシーケンス図の通りとする。BotがPubとSubに分かれているのは、Slack APIの3秒ルール によるもので、Slack相手の責務としては即座にackを返しつつ後続のサーバーに後から返信したり、情報を書き換えるための情報とともに topic 通知して遅延書き換えを行う方法を取っている。

今回はCloud FunctionsでBolt for Pythonでの実装を前提としている。Slack Botそのものの作り方については、上記を参照のこと。

メールをSlackに転送してBOTにイベントを通知

特定のメールアドレスから来たメールを Slackに転送する。

Slackに転送するためにはチャンネルの情報を開いて、インテグレーションからメールアドレスを取得し各メールサービスから転送設定をすれば良い。受け取られたメールは message イベントの サブタイプ = file_share となるため以下のような条件で起動させる。

@app.event("message")
def handle_file_share(context, event) -> None:
    if event.get("subtype") == "file_share":
        if context.channel_id == str(SECRETS["MAIL_CHANNEL_ID"]):
            handle_mail(event)

一個目のファイル情報を json.dumps して文字列として後続処理に移譲。処理対象のチャンネルやスレッド情報も topic に詰め込むことで、本来独立しているSubBot側でもメッセージの書き換えができるようになる。

def handle_mail(event) -> None:
    if "files" in event:
        mail = event.get("files")[0]
        text = json.dumps(mail)

        pub_command(
            channel=event.get("channel"),
            thread_ts=event.get("ts"),
            command="/mail",
            chat_history=[{"role": "user", "content": text}],
        )

メール情報のダウンロードとスクレイピング

ここに一番手間取ったのだけど、転送されたメールの本文については previewplain_text などに入っているはずだが、メール送信元によっては何も入っていなかたり、情報が欠損していたりで処理が安定しない。結果的にプライベートURLから添付のHTMLメールをまるごとダウンロードして処理する方式とした。

        mail_url: str = mail.get("url_private_download")

        if mail_url and self._slack.token:
            self._logger.debug("Download Mail URL: %s", mail_url)
            res = requests.get(
                mail_url,
                headers={"Authorization": f"Bearer {self._slack.token}"},
                timeout=(3.0, 8.0),
            )

プライベートリンクであるため、Slack Bot用の認証トークンをヘッダーに入れる必要がある。このファイルはあくまでSlackからダウンロードしているものであり、元サイトには負担をかけていないのが重要である。

 ダウンロードできたらデコードしたり、スクレイピングするだけだが、ニュースレターは一般にWebサイトに誘導させるためのものであるため、リンク先の管理が重要となる。

    for tag in soup.find_all():
        if tag.name == "a":
            href: str = tag.get("href", "")
            tag.attrs = {}
            tag.attrs["href"] = href
        else:
            tag.unwrap()

このため Beautiful Soap を使って余計な修飾を外しつつ本文+リンク情報を中心に取り出してAIから処理しやすいように整えた。この処理はWebサイトを要約させる際にも役立つ。これだけでもヘッドレス情報としてなかなか読みやすい。

AIアシスタントたちに会話調で要約してもらう

あとはプロンプトエンジニアリングの範疇であるが、せっかくなのでキャラクターナイズさせてみたい。最初にちらみせしたが、いつもの猫耳サイバーパーカー少女+スマートフォンアシスタントはラムダとシグマというオリキャラだったりもする。

未だに調整中だが、現時点では上記のようなプロンプトを gpt4.1-mini に投げかけることで、激安ながら会話調でニュースの内容をさっと理解できるような文章を生成することを試みている。この手の話を実装しようとするたびに偽春菜 a.k.a 伺かにおける任意たん & うにゅうの影響を受けてしまうところはある。

BlocksでSlackでの表示を整える

もっと無機質な要約にするにせよ、もっと冗長な会話にするにせよ。Markdown に変換できたら、Slackにそのまま流し込めることは以前のブログに書いた通りだ。追加で以下のように Blocks を重ねることで表示を整えている。Markdownとmrkdwnで太字マークアップの仕方すら違うつらみ!

    def build_message_blocks(self, content: str) -> list:
        blocks: list[dict] = [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*From: {self._mail.from_name}*",
                },
            },
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"*{self._mail.subject}*",
                },
            },
            {"type": "divider"},
            {"type": "markdown", "text": content},
        ]
        return blocks

ちなみにラジオ音声化する処理も書いたが、現実的にあんまり使わないのとAPI利用費が高いのでディスコンにした。

フィルターを自分で構築していく過渡期の楽しみ

例えば、私たちは会話や文字でばかりコミュニケーションしているが、もし「歌で対話する民族」が存在したらどうだろう?意味もリズムも情動も、同時に伝えるマルチモーダルな伝達手段。書籍が発行されているなかでも授業や動画に価値があるのは、それを受ける側がある種の立体感や冗長化を期待するからだ。文字→音声→映像→身体性。この流れの中で、心地よい意味解釈だけを他者に渡せて、受け取れる揺籠を妄想する。

僕自身の妄想としてはニュースフィードの内容も表現も完全にパーソナライズされていくのだろうという思っているのだけれども、現時点では自分なりの心地よさを調整していく必要がある。僕自身としては学習漫画なり魔法少女なりの元気女子+サイコパスアシスタントの会話が頭に入ってきやすいので選択しているけれど、それが万人にとっての正解ではない。

そのような前提において、情報を取得し、ヘッドレス化し、自分なりの変換器にかけて、適切な場所に連携する一連の流れをDIYすることで心地よい環境を手に入れることができる。これはこれで期間限定のものになるのかもしれないが、休日のDIYにはDIYの楽しみもあるか。