Omarchyのバーから直接SNSに投稿できるプラグインを作った

Omarchyのバーから直接SNSに投稿できるプラグインを作った

BlueskyとMisskeyに一言書きたいだけなのに、ブラウザを開いて、タブを探して、書いて、そのままタイムラインを眺めて15分溶かす。
このムーブを1日に何回もやっていることに気づいて、いい加減どうにかしたくなった。

というわけで omarchy-plugin-social-poster というOmarchyのシェルプラグインを作った。バーのアイコンから投稿と自分宛てメンションの確認だけができる。タイムラインはあえて見せていない

できること

  • テキスト投稿。複数アカウントにチェックを入れればそのままクロスポストになる
  • 自分宛てのメンション・リプライの一覧と未読バッジ、新着のデスクトップ通知
  • 一覧の行から返信

逆にやらないことは、タイムラインの閲覧、いいね・リポスト・フォロー、画像添付。
特にタイムラインは意図的に非対応にした。それが見えた瞬間にブラウザを開いたのと同じことになってしまうので。

インストール

omarchy plugin add https://github.com/polidog/omarchy-plugin-social-poster
omarchy plugin enable io.github.polidog.social-poster --section right

これでバーの右側にアイコンが出る。あとは右クリックでセットアップ画面を開いてアカウントを登録するだけ。アカウント未設定のときは投稿画面の代わりにセットアップ画面が自動で開くようになっている。

キーバインドから投稿画面を直接開きたい場合は ~/.config/hypr/bindings.lua に一行足す。

o.bind("SUPER SHIFT, P", "exec", "omarchy-shell shell summon io.github.polidog.social-poster")

自分は結局これしか使っていない。SUPER+SHIFT+P → 書く → Ctrl+Enter で終わり。

SNS対応はプロバイダーに外出しした

作りとして一番こだわったのはここ。

コア(プラグイン本体)はSNSを一切知らない。 BlueskyもMisskeyもMastodonも、providers/ に置かれた1つの実行ファイルとして実装してある。コアがやるのは、サブコマンドを付けてそれを起動し、stdinにJSONを流してstdoutのJSONを読む、それだけ。

<provider> post   # stdin: {"contractVersion":1, "account":{...}, "text":"...", "replyTo":null}
                  # stdout: {"ok":true, "url":"https://..."}

サブコマンドは info / verify / post / mentions / markRead の5つだけ。info で文字数上限と対応機能(capabilities)を返してもらう仕組みなので、投稿専用のプロバイダーも、読むだけのプロバイダーも書ける。UIは返ってきた capabilities を見てボタンを出し分ける。

なぜこうしたかというと、QMLから任意のユーザーJSを動的ロードするのがQuickshell上でどうにも安全にやれなかったから。だったら拡張点をQMLの外、プロセスの境界に置いてしまえばいい。結果として、

  • 言語が自由(同梱のものはbash + curl + jq。PythonでもGoでも書ける)
  • プロセス分離されるのでプロバイダーが死んでもシェルは巻き込まれない
  • 呼び出しのたびに最新のファイルが実行されるのでホットリロードの概念自体が不要

という副産物もついてきた。

独自のSNSを足したいときは、~/.config/omarchy/social-poster/providers/<name> に契約(docs/PROVIDER.md)を満たす実行ファイルを置いて、accounts.jsonに "provider": "<name>" と書くだけ。コアの変更も再インストールもいらない。最小構成だとこれくらいで動く。

#!/usr/bin/env bash
set -euo pipefail
req=$(cat)
case "${1:?subcommand required}" in
  info)   echo '{"ok":true,"name":"Example","maxChars":500,"capabilities":["post"]}' ;;
  verify) echo '{"ok":true}' ;;
  post)
    text=$(jq -r .text <<<"$req")
    token=$(jq -r .account.token <<<"$req")
    curl -fsS --proto '=https' -X POST https://example.social/api/post \
      -H @- -d "$(jq -n --arg t "$text" '{text:$t}')" <<<"Authorization: Bearer $token" \
      >/dev/null && echo '{"ok":true,"url":null}' \
      || echo '{"ok":false,"error":{"code":"network","message":"post failed"}}' ;;
  *)      echo '{"ok":false,"error":{"code":"invalid","message":"unsupported"}}' ;;
esac

契約を満たしているかのチェック用に tools/provider-check も同梱してある。

なお、この設計上どうしても避けられない点として、プロバイダーを入れるということはそのコードにアカウントのトークンを渡すということでもある。自分で書いたものか、ソースを読んで信用できると判断したものだけ入れてほしい。コアが勝手にプロバイダーをダウンロードしてくることは絶対にない。

返信のスレッド構造をコアが知らなくていい

プロバイダー機構で地味に気に入っているのが replyContext の扱い。

メンション一覧の各行には、プロバイダーが好きな形の不透明な値を replyContext として付けられる。ユーザーが返信すると、その値がそっくりそのまま postreplyTo として戻ってくる。コアは中身を一切解釈しない。

Blueskyなら {uri, cid, rootUri, rootCid} が入っているし、Misskeyなら {noteId} が入っている。コアはそのどちらも知らないまま、両方のスレッド返信が成立する。SNSごとに全然違うスレッドモデルを、コアに条件分岐を1行も書かずに吸収できたのはちょっと気持ちよかった。

トークンの扱い

自分のSNSアカウントの認証情報を預けるものなので、そこはそれなりに気を使った。

  • トークン類はargv・環境変数・ログ・通知のどこにも出さない。プロバイダーへはstdinのJSONで渡す(argvは ps で他のプロセスから丸見えなので)
  • accounts.json / state.json は600で作成し、読み込み時にも検査する。パーミッションが緩いと読み込みを拒否する
  • 同梱プロバイダーは --proto '=https' でhttpsを強制
  • プロバイダーのstderr(診断ログ)はメモリ上のリングバッファにだけ持ち、ファイルには書かない

設定ファイルに平文でトークンを書きたくない場合は、任意のフィールドを $command に置き換えられる。

{ "appPassword": { "$command": "secret-tool lookup service bsky" } }

コアがこのコマンドを実行して、stdoutの値に展開してからプロバイダーに渡す。展開されるのはメモリ上だけで、ファイルには書き戻さない。

こだわった細かいところ

開いただけでは既読にしない

メンション一覧はパネルを開いても既読にならない。未読バッジが消えるのは 󰄬 (すべて既読)か 󰆴 (すべて消す)を自分で押したときだけ。

「通知が来た → とりあえずパネルを開いた → バッジが消えた → 後で読もうと思っていた内容を完全に見失う」というのが本当に嫌いなので、既読化は全部明示操作にした。行単位の 󰅖 で消したメンションは state.json に記録されるから、次のポーリングで同じものが返ってきても一覧には戻ってこない。

コンポーザーがノンモーダル

投稿画面とセットアップ画面は開いている間も背後のウィンドウをそのまま操作できるし、Hyprlandのキーバインドも生きている。

これはセットアップ画面のために必要だった。トークンを発行してブラウザからコピーしてくる作業をするのに、モーダルで画面を占有されると一度閉じるしかない。閉じたら入力中の内容が消える。それはさすがにひどい。
投稿画面でも、記事のURLをブラウザから拾ってきて貼る、みたいな行き来ができるので結果的に便利になった。

アカウントIDは省略できる

accounts.jsonid は、省略すると provider 名がそのままIDになる。1つのSNSに1アカウントなら書く必要がない。

{
  "accounts": [
    { "provider": "bluesky", "service": "https://bsky.social", "identifier": "polidog.bsky.social", "appPassword": "xxxx-xxxx-xxxx-xxxx" },
    { "provider": "misskey", "host": "https://misskey.io", "token": "XXXXXXXX" }
  ],
  "defaultPostTargets": ["bluesky"]
}

最初は id を必須にしていたんだけど、自分で設定を書いていて「misskeyのidはmisskeyに決まってるだろ」という気持ちになったのでやめた。同じproviderのアカウントを複数持つときだけ、両方に id を付けて区別する。

使ってみて

「ブラウザを開かない」というだけのことなんだけど、想像以上に効いている。
投稿までの動線が SUPER+SHIFT+P の1アクションになると、タイムラインを眺めるという行為が生活から自然に減った。SNSをやめたいわけではなくて、ダラダラ見るのをやめたかっただけなので、これはかなり理想的な状態。

メンションの通知だけはちゃんと来るので、反応をもらったことに気づけないという問題も起きていない。

まだv0.1.0で、画像添付には対応していない。プロバイダー契約には拡張の余地を残してあるので、そのうち足すかもしれない。

MITライセンスなので好きに使ってください。プロバイダーを書いたら教えてほしい。

カテゴリ