おひとりさまインスタンスにログを出力する Fediverse (4) Advent Calendar 2023

:::note info
これは，[Fediverse (4) Advent Calendar 2023](https://adventar.org/calendars/8812) の 12 月 22 日の記事です。21日目の記事は [💙mostrで近づいた、Fediverseとの距離](https://millie-may.net/wp/231221adv/) でした。
:::

# 本稿の趣旨
おひとりさまインスタンスで自分宛にダイレクトメッセージを送ったら，自分だけが見られるロガーとして使えるんじゃない？

## おひとりさまインスタンス

Mastodon はジャック・ドーシーの横暴に耐えかね，古き良き Twitter を実現するために生まれました。Twitter が抱える問題の原因は，ただひとりの支配者が SNS のすべてを掌握できてしまうからだと考えたのです。そのことは後にカレン氏によって実証され，人類は再び邪悪な支配者の前に破れることになります。Fediverse は分散化することによって，この問題を解決しようとしています。

![2023-12-22_01-18-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/a40fd033-ba37-1956-47b2-f4ffa30a3904.png)

新参者の私は Mastodon 以外の Fediverse プラットフォームについてはよくわかりませんが，Misskey や Pleroma など，Open Source 実装が公開されているシステムの場合，自前のサーバにインストールすることができます。自分だけがアカウントを作成できるように設定したインスタンスのことは，おひとりさまインスタンスと呼ばれます。

## Logging モジュール

処理状況を追跡し動作検証するには記録を取ることが大切です。Python にはこの目的で作られた [Logging モジュール](https://docs.python.org/3/library/logging.html)があります。例えば，以下のように使います。

```python
import logging
logging.debug('足袋に玉を入れもうして，くるまを打ち付けるで候')
```

実際には `getLogger` を使うべきとか作法がありますが，設定ファイルから書式を読み込めるなど機能も十分にあり，いざ実装するとめんどうなロガーを手軽に使うことができます。

## PPAP

おひとりさまインスタンスには自分しかいません。その自分にダイレクトメッセージを送ると，他のサーバへ通知されることはありません。Mastodon は End-to-End Encryption ではないため，送受信者とその経路は暗号化されていません。しかし，おひとりさまインスタンス内であれば，自分以外にアクセスできないため，Slack に通知していたような機密性の水準の投稿はできそうです。

# 準備するもの

* おひとりさまインスタンス
* 投稿用 Access Token

おひとりさまインスタンスはいくつかの方法で準備できます。本稿をここまで読んだ方であれば，VPS をいくつか運用しているでしょうから，Docker Compose を使って稼働させるのがお手軽かもしれません。日本語のサポートがあるホスティングサービス（[Hostdon](https://hostdon.jp) 等）を利用する方法もあります。直近の Advent Calendar 19日目 [おひとり様 Mastodon サーバーを支える技術](https://fohte.net/blog/posts/2023-12-19-solo-mastodon-server) などをご参照ください。私は固定 IP アドレスを振った自宅 Linux サーバ上で，他のサービスとともに Docker コンテナで動作させています。

投稿用 Access Token は，Preferences | Development | New application から，`write` にだけチェックを入れて作成します。

![2023-12-22_02-42-31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/105d483f-60fd-9c92-444d-ca521b04ca74.png)

すると，このような雰囲気のものが生成されます。必要になるのは `Your access token` とあるものだけです。

`Client key` と `Client secret` は API を利用するツールそれぞれに対応する，いわゆる ID／パスワードに相当する情報です。これらによりクライアントが認証されると，API リクエストに使うための `Access Token` を生成することができます。今回は Mastodon のウェブインターフェイスを利用したため，これら 3 つが一度に生成されました。

# ログの投稿方法

## cURL を使う

以下のように `curl` からインスタンスへアクセスすることで，Mastodon API を利用できます。

```
curl -X POST \
    -H 'Authorization: Bearer {ACCESS_TOKEN}' \
    -d 'visibility=direct' \
    -d 'status={MESSAGE}'
    https://{INSTANCE_NAME}/api/v1/statuses
```


ただし，

* `{INSTANCE_NAME}`: おひとりさまインスタンス　例：mstdn.jp
* `{ACCESS_TOKEN}`: 上記の準備で得たアクセストークン
* `{MESSAGE}`: 投稿するメッセージ　`@自分のID` を前置する

例えば，以下のようなリクエストを投げると，こんな感じで Private mention が自分に飛んできます。

```
curl -X POST \
    -H 'Authorization: Bearer vvjPKgFtlbCsVVZqKzRWMk_jTOBEuc4urvX2on41tyw' \
    -d 'visibility=direct' \
    -d 'status=@rino 裏金がバレてしまった！' \
    https://hello.world/api/v1/statuses
```

![2023-12-22_03-12-41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/8ef2a6c0-c95f-00c2-86fd-6328a29f8eb3.png)

ダイレクトメッセージは Private mentions のページで一覧することができます。

![2023-12-22_03-16-06.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/f87f6788-e4ce-d274-204c-f7be2131e0b2.png)

私が使用している Ivory では，目立った感じの音と色で知らせてくれます。

## Mastodon.py を使う

Python から投稿するのであれば，次のような雑ロガーを実装しておくと便利です。

```:requirements.txt
mastodon.py
python-dotenv
```

```:.env
ACCESS_TOKEN=
BASE_URL=https://
DESTINATION=@
```

```python:mlogging.py
#!/usr/bin/env python3

# logging for mastodon

import datetime
from mastodon import Mastodon
import time

class MLogging:
  # Logging levels
  Levels = {
    'EMERG' : 80, 'EMERGENCY' : 80,
    'ALERT' : 70,
    'CRIT'  : 60, 'CRITICAL'  : 60,
    'FATAL' : 60,
    'ERROR' : 50,
    'WARN'  : 40, 'WARNING'   : 40,
    'NOTE'  : 30, 'NOTICE'    : 30,
    'INFO'  : 20,
    'DEBUG' : 10,
  }

  # Constructer
  # api_base_url  url of the mastodon instance
  # access_token  api access token
  # destination   username to be sent (@user for local, @user@instance for remote)
  # level         minimum logging level
  def __init__(self, api_base_url, access_token, destination, level='WARN', time_format='%Y-%m-%d %H:%M:%S'):
    # Store given information to local variables
    self.api_base_url = api_base_url
    self.access_token = access_token
    self.destination = destination
    self.level = level if level.upper() in self.Levels else 'WARN'
    self.time_format = time_format
    self.retry_count = 10
    self.retry_interval = 2

    # Create mastodon object
    self.mastodon = Mastodon(
      api_base_url = self.api_base_url,
      access_token = self.access_token,
    )

  # Send status directly to a user
  # level   logging level
  # status  message
  # media   list of images (specify empty list if no image)
  def __send_direct(self, level, status, media=[]):
    # Set level to WARNING if the given level is not in the predefined list
    if not level.upper() in self.Levels:
      level = 'WARN'

    # Send status only if the given logging level is above than criteria
    if self.Levels[self.level] <= self.Levels[level.upper()]:
      # Obtain current time and format
      now = datetime.datetime.now()
      dt = now.strftime(self.time_format)

      # Upload media if media files are specified
      media_files = []
      if media:
        for m in media:
          count = 0
          d = self.mastodon.media_post(m)
          while count < self.retry_count and d['url'] is None:
            count += 1
            d = self.mastodon.media_update(id=d['id'])
            time.sleep(self.retry_interval)
          media_files.append(d)

      # Send status directly to the user
      self.mastodon.status_post(
        '%s [%s][%s] %s' % (self.destination, level, dt, status),
        visibility = 'direct',
        media_ids = media_files if media else None,
      )

      # Wait
      time.sleep(1)

  def basicConfig(self, level='WARN', time_format='%Y-%m-%d %H:%M:%S'):
    if level in self.Levels:
      self.level = level
    self.time_format = time_format

  def emergency(self, status, media=[]):
    self.__send_direct(level='EMERG', status=status, media=media)

  def alert(self, status, media=[]):
    self.__send_direct(level='ALERT', status=status, media=media)

  def critical(self, status, media=[]):
    self.__send_direct(level='CRIT', status=status, media=media)

  def fatal(self, status, media=[]):
    self.__send_direct(level='FATAL', status=status, media=media)

  def error(self, status, media=[]):
    self.__send_direct(level='ERROR', status=status, media=media)

  def warning(self, status, media=[]):
    self.__send_direct(level='WARN', status=status, media=media)

  def notice(self, status, media=[]):
    self.__send_direct(level='NOTE', status=status, media=media)

  def info(self, status, media=[]):
    self.__send_direct(level='INFO', status=status, media=media)

  def debug(self, status, media=[]):
    self.__send_direct(level='DEBUG', status=status, media=media)

def main():
  from dotenv import load_dotenv
  import os

  # Load the environment variables from .env file
  load_dotenv()

  mlog = MLogging(
    api_base_url = os.getenv('BASE_URL'),
    access_token = os.getenv('ACCESS_TOKEN'),
    destination = os.getenv('DESTINATION'),
  )

  mlog.basicConfig(level='DEBUG')

  mlog.critical('CRITICAL MESSAGE')
  mlog.error('file not found', media=['./image1.png', './image2.png'])
  mlog.warning('WARNING MESSAGE')
  mlog.info('INFO MESSAGE')
  mlog.debug('DEBUG MESSAGE')
  return 0

if __name__ == "__main__":
  main()

```

![2023-12-22_03-30-48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/241519/f3d31206-efd1-c968-09d4-961e21fd6425.png)

# まとめ

いくつか Bot を稼働させているのですが（例えば [今年の残り日数](https://mstdn.jp/@remaining_days)），失敗したときなどに，ローカルのログへエラーを書き出しただけではいまいち気づかず，ホームタイムラインへ通知されたら便利だなあと思って試してみた次第です。

うちには爬虫類のペットがおり，一年中 28 ℃くらいに維持しているんですが，空調の停止によりかわいい爬虫類が苦しんでしまうなど耐えがたいため，室温変動を警告するといった用途でも使えそうです。

参考になれば幸いです。