🧲

MastodonのAPIをMisskeyでも使えるようにした話

2021/11/12に公開

はじめまして、CyberRexと言います。高校生で趣味でいろいろやっています。
Zennで記事を書くのはこれが初めてです。よろしくお願いします。

開発動機

MisskeyでNowPlaying4Droidが使いたかった。
NowPlaying4Droidは、今聴いている曲を共有できるAndroidアプリです。
Mastodonへの自動投稿には対応していますが、Misskeyには対応していません。

そもそもMisskeyって何

MisskeyはActivityPub準拠のマイクロブログプラットフォームです。

問題点

ActivityPubに対応はしているものの、MastodonとAPIの互換性がないので、MastodonのAPIを使うアプリやサービスでMisskeyに対応させるには、中間に変換するサーバーが必要になります。

今やろうとしている事を図に表すとこんな感じです。

アプリが何をリクエストしているのか覗く

まずはアプリがどのAPIにアクセスしているのか知る必要があります。
適当にFlaskのサーバーを立ち上げて404になってもいいのでアクセスさせます。

するとこんなログが取れました。
x.x.x.x - - [06/Nov/2021 00:00:00] "POST /api/v1/apps HTTP/1.0" 404 -
まず /api/v1/apps にPOSTしてアプリケーションの登録をしているようです。
このJSONデータが送られてきました。

{
	"client_name": "NowPlaying4Droid",
	"redirect_uris": "...",
	"scopes": "read write follow"
}

仕様はMastodon公式ドキュメントにあります。
https://docs.joinmastodon.org/methods/apps/

サーバーはこのリクエストを処理してアプリケーションを登録できたように見せかければ良いようです。

実装

アプリケーション登録

応答するときは、アプリケーションのIDとシークレットキーを返してあげる必要があります。
ここではアプリケーションIDは適当にUUIDv4で生成して渡します。
IDはMisskeyとの連携に必要になるのでデータベースに保存します。

    uid = str(uuid.uuid4())
    with db.cursor() as cur:
        cur.execute('INSERT INTO oauth_pending(session_id, client_name, scope, redirect_uri, instance_domain) VALUES(%s, %s, %s, %s, %s)',
        (uid, request.form['client_name'], request.form['scopes'], request.form['redirect_uris'], None))
        cur.execute('commit')
    return json.dumps({
        'id': 'fakeoauthid001',
        'client_name': request.form['client_name'],
        'redirect_uri': request.form['redirect_uris'],
        'client_id': uid,
        'client_secret': 'fake123'
    })

id、client_secretは未使用なので適当な値を返しています。

OAuth

アプリケーションの登録に成功したと分かると、次は /oauth/authorizeclient_idscoperedirect_uriのパラメータをつけて、ユーザーにアクセスさせます。

client_id は先ほど作成したIDを付けてやってくるはずなので、データベースに照会して、存在したら通してあげる、という風にします。

Misskeyは分散SNSであるため、特定のドメインだけで運用されているわけではなく、無数に存在します。そのため、ドメインを入力させる画面を持たせます。

    client_id = request.args['client_id']
    scope = request.args['scope']
    redirect_uri = request.args['redirect_uri']
    return render_template('select_instance.html', session_id=client_id)

ユーザーにドメインを入力してもらったら、サーバーに一旦送信させて、MiAuth(Misskey独自のOAuth認証システム)にリダイレクトさせるようにします。

    res = make_response('', 302)
    res.headers['Location'] = f'https://{instance_domain}/miauth/{session_id}?' + build_query(
        name=sesinfo['client_name']+' (via MaMi Integration)',
        callback=f'https://{request.host}/oauth/integration_callback/{instance_domain}',
        permission='read:account,write:notes,write:drive,read:drive'
    )
    return res

MiAuthでは https://{instance_domain}/miauth/{session_id}?name=ApplicaionName&callback=...&permission=... というURLで認証させるようになっており、session_id は重複しなければOKとなっています。permissionに指定する値はMastodonのscopeとは異なり、細かく項目が設けられています。最小限として、アカウント情報の読み取り、投稿とドライブの読み書きをできるようにしました。
コールバックは /oauth/integration_callback/{instance_domain} としています。
データベースに記録していないのはできるだけコードを減らしたかったからです (後述のとおりトークンの検証はしています。

ユーザーが許諾すると、先ほどのコールバックURLに?session=SESSION_ID がついた形で戻ってきます。このMisskeyから与えられたセッションIDを問い合わせて、正当性を確認するとともに、アクセストークンを受け取ります。
https://{instance_domain}/api/miauth/{session_id}/check を叩いて問い合わせます。

    oauth_req = requests.post(f'https://{domain}/api/miauth/{args["session"]}/check')
    if oauth_req.status_code != 200:
        return make_response('セッションが正当なものであることを確認できませんでした。(MIAUTH_FAILED_'+str(oauth_req.status_code)+')', 500)
    data = oauth_req.json()
    if not data['ok']:
        return make_response('セッションが無効です。(MIAUTH_INVALID_SESSION)', 403)

APIからは次のような応答が返ってきます。

{
	"ok": true,
	"token": "...",
	"user": {...}
}

セッションIDが有効であればokがtrueになって、tokenにアクセストークンが入ります。
トークンは、データベースに一時的な仮想のMastodon用のIDと関連付ける形で保存します。

Misskeyのトークンが取得出来たら、アプリが指定したコールバックURLにリダイレクトします。
NowPlaying4Droidの場合は、カスタムスキームでアプリに戻る仕様になっていました。
この時、URIの後ろに?code=CODE を付けます。このcodeは後で使うので、これも新しくUUIDv4で作成してデータベースに保存しておきます。

アプリに戻ると、最後に/oauth/token にPOSTで飛んできます。
client_idcodegrant_type というパラメータ付きです。

client_idは、一番最初に発行したID、codeは先ほど作成したIDが入っているはずです。
grant_typeはauthorization_codeで固定のようです。

セッションIDが存在すること、codeが一致していることを確かめたら、Mastodonアプリ用のトークンを発行してデータベースに登録、応答します。

    data = request.form
    session_id = data['client_id']
    with db.cursor(dictionary=True) as cur:
        cur.execute('SELECT * FROM oauth_pending WHERE session_id = %s', (session_id,))
        sesinfo = cur.fetchone()
        if not sesinfo:
            return make_response('No such session id', 400)
        if sesinfo['authcode'] != data['code']:
            return make_response('Invalid code', 400)
    # ベアラートークン登録
    access_token = str(uuid.uuid4())
    with db.cursor() as cur:
        cur.execute('INSERT INTO oauth(misskey_token, mstdn_token, instance_domain) VALUES (%s, %s, %s)'
            , (sesinfo['misskey_token'], access_token, sesinfo['instance_domain']))
        cur.execute('commit')
    # 応答
    return json.dumps({
        'access_token': access_token,
        'token_type': 'bearer',
        'scope': 'read write',
        'created_at': int(time.time())
    })

そしてもう1回、アプリから /api/v1/accounts/verify_credentials にPOSTが投げられてきます。データベースから探して200や401を返してあげています。
そして、アカウントが正しいものであればアカウント情報を返す必要があります。
そのためMisskeyのトークンを使ってMisskeyのアカウント情報を取ってきて、Mastodonのアカウント情報に変換して応答しています。

Misskey APIへの問い合わせにはYuzuRyoさん作のMisskey.pyを使用しています。

    from misskey import Misskey
    # Misskeyインスタンスに問い合わせ
    m = Misskey(address=oauth_info['instance_domain'] ,i=oauth_info['misskey_token'])
    try:
        profile = m.i()
    except:
        return make_response('Misskey API error', 500)
    # プロフィールデータ再構成
    profile_mastodon = {
        'id': profile['id'],
        'username': profile['username'],
        'acct': profile['username'],
        'display_name': profile['name'],
        'avatar': profile['avatarUrl'],
        'avatar_static': profile['avatarUrl'],
        'header': profile['bannerUrl'],
        'header_static': profile['bannerUrl'],
        'note': profile['description'],
        'url': profile['url'],
        'locked': profile['isLocked'],
        'bot': profile['isBot'],
        'followers_count': profile['followersCount'],
        'following_count': profile['followingCount'],
        'statuses_count': profile['notesCount'],
        'fields': profile['fields']
    }

    res = make_response(json.dumps(profile_mastodon), 200)
    res.headers['Content-Type'] = 'application/json'
    return res

これでOAuthの実装はできました。この部分が長かった。

メディアアップロード

Mastodonではメディアアップロードする際に /api/v1/media を叩くようです。
アップロードされたデータをそのままMisskeyに渡してやってドライブにアップロードします。

    # Misskeyのドライブにアップロード
    m = Misskey(address=oauth_info['instance_domain'] ,i=oauth_info['misskey_token'])
    try:
        # streamはBinaryIOクラス
        drive_file = m.drive_files_create(request.files['file'].stream)
    except Exception as e:
        print(e)
        return make_response('Misskey API error', 500)

ファイルの情報をMastodon用に変換します。
MisskeyではファイルIDは英数字が使われていますが、Mastodonでは数字だけ使われているようなので、メモリ上に変換テーブルを作ります。

    # Mastodonは数字のメディアIDのみ受け付けるため、変換テーブルに登録して投稿用に備える
    fake_drive_id = f'{time.time():.0f}{random.randint(0,999999)}'
    drive_mediaid_table[fake_drive_id] = drive_file['id']


    res = make_response(json.dumps({
        'id': fake_drive_id,
        'type': 'image',
        'url': drive_file['url'],
        'preview_url': drive_file['thumbnailUrl'],
        'remote_url': None,
        'text_url': None,
        'meta': {},
        'description': None,
        'blurhash': 'UFBWY:8_0Jxv4mx]t8t64.%M-:IUWGWAt6M}'
    }))
    return res

これでメディアアップロードの実装はできました。

ノート投稿

最後にノート投稿できるようにします。
Mastodonでは /api/v1/statuses に投げるとトゥートできるようです。

公開設定などは使いまわせないのでMisskeyの設定に合わせます。
メディア添付もできるようにします。メモリ上の変換テーブルにあれば添付しています。
すべての項目を設定できるわけではありません。現在は本文とメディア添付、公開範囲の設定ができるぐらいです。

    data = request.form
    text = data.get('status')
    sensitive = data.get('sensitive') # unused
    visibility = data.get('visibility')
    if visibility == 'private':
        visibility = 'followers'
    elif visibility == 'unlisted':
        visibility = 'home'
    media_ids = data.getlist('media_ids[]')
    if not text:
        return make_response('No text', 400)
    # ...省略
    
    drive_ids = []
    for mid in media_ids:
        if mid in list(drive_mediaid_table.keys()):
            drive_ids.append(drive_mediaid_table[mid])
	    
    # Misskeyにノート投稿
    m = Misskey(address=oauth_info['instance_domain'] ,i=oauth_info['misskey_token'])
    try:
        if drive_ids:
            note_d = m.notes_create(text, file_ids=drive_ids, visibility=visibility)
        else:
            note_d = m.notes_create(text, visibility=visibility)
    except Exception as e:
        print(e)
        return make_response('Misskey API error', 500)
    
    note = note_d['createdNote']

    toot_data = {
        'id': note['id'],
        'created_at': note['createdAt'],
        'in_reply_to_id': None,
        'in_reply_to_account_id': None,
        'sensitive': sensitive,
        'spoiler_text': None,
        'visibility': visibility,
        'language': None,
        'content': text,
        'reblog': None
    }
    return make_response(json.dumps(toot_data), 200)

これでノート投稿できるようになりました。

実際に使ってみる

NowPlaying4Droidで、Mastodonインスタンスのアドレスを入力するところに変換システムのアドレス (mami.cyberrex.jp)を入れます。
すると、うまく選択画面が現れました。

「続行」を押すとMisskeyの認証画面に飛びます。

「許可」を押すと各種処理が進んで、登録に成功します。

早速曲を再生してみます。すると、うまく投稿できました。公開範囲をフォロワー限定に設定しているのも反映されています。(右上の鍵アイコン)

動作確認できました。

おわりに

NowPlaying4Droidはオープンソースで、実はGitHubでIssueを受け付けていたみたいです。そこでFeature Requestしたほうが早かったかもしれない・・・。
ですが少し勉強はできたので良かったかなと思います。

この変換サービスは MaMiという名前で mami.cbrx.io で動かしていますのでぜひ試してみてください。

Discussion