Featured image of post GoでMisskeyのCLIクライアントを作った話 (Misskey Advent Calender 2022)

GoでMisskeyのCLIクライアントを作った話 (Misskey Advent Calender 2022)

この記事はMisskey Advent Calendar 2022 16日目の記事です。

こんにちは。たっくん(@mikuta0407)です。

GoでMisskeyのCLIクライアントを作ったので、宣伝も兼ねてアドカレに参加してみようと思います。よろしくお願いします。


早速ですが、こんな感じのCLIなMisskeyクライアントを作りました。

mikuta0407/misskey-cli

https://github.com/mikuta0407/misskey-cli

ここで配布してます。

もし良ければ是非。Linux(amd64/aarch32/aarch64)/macOS(M1)で動作を確認しています。

MITライセンスではありますが、割と適当にお使いください。(使用ライブラリ的にMITライセンスが楽だなとなった感じです)

使い方はGitHubのREADMEにあるのである程度割愛するとして、この記事では実装方法について振り返りも兼ねて紹介しようと思います。

ちなみに、書いている最中に引用リノートの投稿/表示に対応していないことに気づきましたが、修正する時間がなかったので今後追加予定の機能ということにして……そのまま行きます。

動機

mattnさんのtwty (https://github.com/mattn/twty) が作業中TwitterのカモフラージュにCLIでTwitterするのに便利だったので、Misskey版っぽいのも作りたいなぁと思い、やってみました。

あと、ついでにGoを大真面目に学んでいるところでもあったので、アウトプットという意味でも何か形にしたかったので、「せっかくtwtyがGoなら、Goで作ってシングルバイナリかつマルチプラットフォームやるぞ」という気持ちもありました。

使ったもの

  • Go言語 (1.19)
    • 標準以外のパッケージ
      • github.com/BurntSushi/toml
      • github.com/buger/jsonparser
      • github.com/spf13/cobra
      • golang.org/x/crypto
  • Misskey API

各実装部分

ここからは、どんな感じで実装していったのかについて、振り返っていきます。

cobra

サブコマンドを実装するため、cobraを採用し、tl``note``renote の3つのサブコマンドを実装しました。

このサブコマンドの中で更にフラグを使ってモードを分けています。実際に階層を表すと(ただのヘルプの日本語訳になりそうですが)、

  • tl: TL表示
    • -l: 一度に表示する件数
    • -m: TLのモード(ホーム/ローカル/グローバル)
  • note: 投稿系
    • -d: 投稿削除
    • -r: リプライ
    • なし: 通常投稿
  • renote: リノート
    • 引用RTには現状対応していません。(簡単にできるんだけどコマンド体系で悩み中)
  • 共通フラグ
    • -c: コンフィグファイルのパス指定(オプション)
    • -t: インスタンス切り替え指定 こんな感じになります。

また、表示件数とTLのモードとコンフィグファイルのパスは指定されなかった場合のデフォルト値を指定している他、インスタンス切り替えに関しては(後述しますが)指定されなかった場合はコンフィグファイルの一番上のインスタンスを使うようにしています。

cobra自体の説明はここでしてもあまり意味がないため、これくらいにさせてください。

コンフィグ周り

インスタンスの接続情報を持つコンフィグファイルはtoml形式で記述します。

(この記事を執筆している段階では、プログラム側からコンフィグファイルを生成する機能は無いので、手動で記述する必要があります。)

例:

1
2
3
4
5
[[Instance]]
  host = https://misskey.io
  name = misskey.io
  token = hOgEFuGA
  username = mikuta0407

tomlのパースはgithub.com/BurntSushi/tomlで行っています。

インスタンスの接続情報はtoml内に複数記述できるようにしているので、構造体の宣言はインスタンス情報が入った構造体(InstanceInfo)の配列(Instance)、というようにしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type Config struct {
    Instance []InstanceInfo `toml:Instance`
}

type InstanceInfo struct {
    Host     string `toml:host validate:required` // 接続先ホスト
    Name     string `toml:name validate:required` // フレンドリ名(-iオプションで指定する文字列)
    Token    string `toml:token validate:required` // Misskeyのアクセストークン
    UserName string `toml:username validate:required` // コンソールに表示するユーザー名(本来はなくてもいいがtoml内の識別用に一応
}

そして、toml.DecodeFileでtomlファイルの内容を構造体配列に入れています。

1
2
3
4
5
func ParseToml(fileName string) (Config, error) {
    var configs Config
    _, err := toml.DecodeFile(fileName, &configs)
    return configs, err
}

この後は、例えば2つめのインスタンス情報のトークンが欲しければ、

1
config.Instance[1].Token

のように書くことで取得できるようになります。

ちなみに、-iオプションでインスタンスを指定しなかった場合の挙動ですが、自動で[0]の内容を使うようにしています。

また、インスタンス指定があった場合は

1
2
index, isExist := include(configs.Instance, instanceName) // 第1引数で渡したInstanceInfo配列から第2引数のインスタンス名が含まれている添え字を返す
instanceInfo = configs.Instance[index]

といったコードで、「指定されたインスタンスのフレンドリ名が含まれている添字を取得し、そこの内容を見に行く」という処理をしています。(実際は指定されたものが無かったときのエラーハンドリングもしています)

このコンフィグファイル周りの処理は、misskeyパッケージのNewClientでクライアントインスタンス(言葉が厄介になってきた)を作成するときにtomlのパースを呼び出しつつ、インスタンス情報を確定した単一のInstanceInfo構造体を含むCleiint構造体を返すことで行っています。

…日本語で書くとむしろわかりにくいですね。

興味がある方は https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/misskey.go#L18-L48 を見ていただけるとわかるかと思います。

APIのPOST

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/apipost.go#L11-L36

このコードの話です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
func (c *Client) apiPost(jsonByte []byte, endpoint string) error {

    req, err := http.NewRequest(
        POST,
        c.InstanceInfo.Host+/api/+endpoint,
        bytes.NewBuffer(jsonByte),
    )
    if err != nil {
        return err
    }

    client := &http.Client{}
    resp, err := client.Do(req)

    c.resBuf = new(bytes.Buffer)
    if _, err = io.Copy(c.resBuf, resp.Body); err != nil {
        return err
    }

    if resp.StatusCode < 200 || resp.StatusCode > 300 {
        fmt.Println(resp.StatusCode, c.resBuf)
        os.Exit(1)
    }
    defer resp.Body.Close()
    return nil
}

MisskeyのAPIはPOSTにJSONのリクエストボディをくっつけてHTTPのリクエストを送ることでレスポンスをもらえます。

HTTP APIはすべてPOSTで、リクエスト/レスポンスともにJSON形式です。

https://misskey-hub.net/docs/api/

misskeyパッケージのClientapiPostというメソッドで、引数に渡されたJSON文字列とエンドポイント先(API名?)を使ってPOSTしています。

このapiPostというメソッドは、net/httpパッケージのhttp.NewRequestを使ってPOSTし、ステータスコードが200であれば返却されたレスポンスボディをClient構造体内のresBuf(*bytes.Buffer)に入れています。(200以外はerrorを返すので受けた側でエラーハンドリング)

例えば新規投稿では、apiPostメソッドにアクセストークンと投稿文字をJSONに流し込んだjsonByte(json文字バイト列)と、notes/createという投稿用エンドポイントをc.apiPost(jsonByte, notes/create)として渡すことで投稿が出来ます。渡されたエンドポイントの文字列は、最初のコンフィグファイルパースのときにClientInstanceInfo.Hostに入っているMisskeyのホスト先に「/api/」と渡されたエンドポイント名を結合することでリクエスト先を生成しています。

アクセストークンについては、リクエストボディのJSON内ではiというパラメータに渡すことで利用ができます。

(ちなみに、現状misskey-cliはアクセストークンの取得手段を実装していないため、tomlに記載するアクセストークンはブラウザ版から手動で取得する必要があります)

APIのドキュメントを見ると本当に色々と機能があり、眺めてみると面白いのでオススメです。

投稿・リプライ・リノート・削除

投稿

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/postnotes.go#L10-L40この関数の話です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func (c *Client) CreateNote(text string) error {
    body := struct {
        I    string `json:i`
        Text string `json:text`
    }{
        I:    c.InstanceInfo.Token,
        Text: text,
    }

    jsonByte, err := json.Marshal(body)
    if err != nil {
        return err
    }

    if err := c.apiPost(jsonByte, notes/create); err != nil {
        return err
    }

    id, _ := jsonparser.GetString(c.resBuf.Bytes(), createdNote, id)
    text, _ = jsonparser.GetString(c.resBuf.Bytes(), createdNote, text)

    fmt.Println(Create Note: @ + c.InstanceInfo.UserName +  ( + c.InstanceInfo.Host + ))
    printLine()

    str := fmt.Sprintf(Note Success! id : %s\n\%s\, string(id), string(text))

    fmt.Println(str)

    return nil

}

Misskeyのノート投稿のAPI仕様は、

https://misskey-hub.net/docs/api/endpoints/notes/create.htmlにありますが、

<host>/api/notes/create宛に、トークンと投稿内容をリクエストボディに入れてPOSTすることで投稿ができます。

curlで投稿をしてみると、

1
curl -X POST -d '{i:hogehugaToken, text:ねこはいます ねこでした。}' https://misskey.io/api/notes/create

を実行すれば、

このように投稿ができます。(投稿したらすぐにスタンプが付いた。これぞMisskey)

この新規投稿に関しては、Client構造体のメソッドCreateNoteとして実装しました。

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/postnotes.go#L10-L40

引数に渡されたテキストをbody用構造体にトークンとともに入れてjson.Marshalで文字バイト列jsonByteを作成し、先述のapiPostにjsonByteとエンドポイント先を渡すことで、投稿をしています。

misskey-cliで投稿をすると、以下のように投稿IDと投稿内容が表示されるようになっています。

1
2
3
4
5
$ ./misskey-cli note -i io 眠すぎる
misskey-cli  Create Note: @mikuta0407 (https://misskey.io)
=====================================
Note Success! id : 98jwnbd39s
眠すぎる

この部分は、POSTしたときに戻ってきたJSONから値を取り出しています。

戻ってくるJSONは以下のようになっています。(実装上はClient構造体内resBufに入っています)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
  createdNote: {
    id: 98jwi9p10i,
    createdAt: 2022-12-09T09:59:52.981Z,
    userId: 8rhxi7gw77,
    user: {
      id: 8rhxi7gw77,
      name: null,
      username: mikuta0407,
      host: null,
      avatarUrl: https://s3.arkjp.net/misskey/thumbnail-b13bb7e7-c86b-47f4-bed7-aee75c5febf6.webp,
      avatarBlurhash: yILWR+-U7fS$xGX9s900WB9cR-xskCWBAwR*}TsmWBnPOE~CRk-pxaIVaes.Ads:xZjbNaS#r@%gkCNFWBWXi{tQ=yjZI;R*xGt6WC,
      avatarColor: null,
      emojis: [],
      onlineStatus: active,
      driveCapacityOverrideMb: null
    },
    text: ねこはいます ねこでした,
    cw: null,
    visibility: public,
    renoteCount: 0,
    repliesCount: 0,
    reactions: {},
    emojis: [],
    fileIds: [],
    files: [],
    replyId: null,
    renoteId: null
  }
}

この中で表示したい部分はcreateNoteの中のidと、createNoteの中のtextの2つです。

Go言語で普通JSONのパースを行うときは、JSONの中身に対応した構造体を宣言してそこにパースしていきますが、Misskeyの返却APIはパラメータが多く、かつ場合によっては中身の構造が増える(特に投稿ではなくRenote/Replyで発生する)ため、PHPのjson.decodeのように動的にパースできないGo言語では実装が大変です。

そこで、JSONのバイト列からその場で欲しい値をキー名ベースで持ってきてくれるgithub.com/buger/jsonparserを使って値を取得することにしました。

1
2
id, _ := jsonparser.GetString(c.resBuf.Bytes(), createdNote, id)
text, _ = jsonparser.GetString(c.resBuf.Bytes(), createdNote, text)

これであれば構造体をわざわざ宣言する必要がないので、JSONの構造が変わったとしても対応が可能となります。

あとはidtextfmt.Sprintf等で表示すればよいので、このライブラリには大変助けれられました。

リプライ

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/postnotes.go#L42-L71

ここのReplyNote関数の話です(CreateNoteと重複する部分が多いためコード貼るのは省略)

リプライは投稿と同様にnotes/createのAPIを使用します。

先程はリクエストボディ内はトークンと内容だけでしたが、リプライするにはreplyIdを追加することでリプライが出来ます。

replyIdを追加することで、指定したノートのID(ノートのURL等で最後についている文字列。↑の投稿例だと98jwnbd39sがノートID)にリプライをする、指示しています。

-rオプションでもらったreplyIdを追加して新規投稿と同様にPOSTしているだけなので、後の解説は割愛します。

リノート

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/postnotes.go#L73-L100

ここのRenoteNote関数の話です。コード貼るのは省略。

リノートも投稿・リプライと同様にnotes/createのAPIを使用します。

リプライの時のreplyIdの代わりにrenoteIdを付与してPOSTするだけでリノートが出来ます。

現状misskey-cliでは実装できていませんが、引用リノートがMisskeyには存在します。

引用リノートをする場合は、リプライとほぼ同じようにトークン・renoteId・テキストの3つをPOSTすることで引用リノートができます。

(余談) クライアントである以上はmisskey-cliでもちゃんと追加はしたいのですが、 misskey-cli renote 98jwnbd39sといったようにすることでリノートをするよう実装してしまったため、引用リノートをするときはどういうコマンド/オプション体系にすればいいかを悩んでしまっています。

削除

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/postnotes.go#L102-L127

ここのDeleteNote関数の話です。

削除はエンドポイントこそapi/notes/deleteと投稿系と異なりますが、リクエストボディ自体はほぼリノートと同様で、トークンと削除したいnoteIdを指定してPOSTすることで削除が行なえます。

(これ以上書くことがない)

TL取得

TL取得が一番時間がかかった部分です。

https://github.com/mikuta0407/misskey-cli/blob/main/misskey/gettimeline.go

このコードの話です。

タイムライン取得は、トークンと表示数をリクエストボディに入れ、エンドポイント先を指定することでローカル/グローバル/ホームのTLを指定表示件数分取得することが出来ます。

ドキュメント:

例えば、ローカルTLの取得(最新2件)をcurlでやるとすると、

1
curl -X POST -d '{i:hogehugaToken, limit:2}' https://misskey.io/api/notes/local-timeline

となります。

実際にcurlで最新1件のノートを取得してみると、こんなJSONが降ってきます。(とりあえず1件)、

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[
  {
    id: 98iwcr1ime,
    createdAt: 2022-12-08T17:07:49.350Z,
    userId: 8rhxi7gw77,
    user: {
      id: 8rhxi7gw77,
      name: null,
      username: mikuta0407,
      host: null,
      avatarUrl: https://s3.arkjp.net/misskey/thumbnail-b13bb7e7-c86b-47f4-bed7-aee75c5febf6.webp,
      avatarBlurhash: yILWR+-U7fS$xGX9s900WB9cR-xskCWBAwR*}TsmWBnPOE~CRk-pxaIVaes.Ads:xZjbNaS#r@%gkCNFWBWXi{tQ=yjZI;R*xGt6WC,
      avatarColor: null,
      emojis: [],
      onlineStatus: online,
      driveCapacityOverrideMb: null
    },
    text: お腹空いてきちゃった・・・,
    cw: null,
    visibility: public,
    renoteCount: 0,
    repliesCount: 0,
    reactions: {},
    emojis: [],
    fileIds: [],
    files: [],
    replyId: null,
    renoteId: null
  }
]

こんな感じでjson配列がもらえます。この配列をjsonparser.ArrayEachを使って要素ごとに処理しTL表示をしています。

TLを構築する投稿のパターンは通常投稿・リプライ・リノートの3つがあります。執筆時点では引用リノートに非対応ですが(ごめんなさい)、これら3つのパターン分けは、

  • renoteIdがあるか→あればリノート
  • replyIdがあるか→あればリプライ投稿
  • 両方無かったら→通常投稿 という分岐で行うことが出来ます。

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/gettimeline.go#L59-L96

通常投稿

通常投稿の場合は、上記例のパターンのJSONが降ってきます。

そして、コンソールへの表示は

1
2022/12/14 23:29:29 名前(@ID)        本文    (添付有り)(98abcdef)

といったようにするため、JSONの中で必要なものは - 投稿者名(user.name)

  • 投稿者ID(user.username)(@hogeで始まるもの)
  • 投稿者インスタンスホスト名(user.host)(@hoge@misskey.ioの部分)
  • 投稿時間(createdAt)
  • 本文(text)
  • 投稿ID(id)
  • 猫フラグ(user.isCat)
  • 添付ファイル情報(files) となります。

これらを取り出しているのが、pickNote関数となります。

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/gettimeline.go#L111-L158

この関数に投稿のjson配列の1要素のバイト列を渡すし、新規投稿のところでも書いたjsonparserを使って、json用の構造体の宣言なしに値を取り出しをしています。(noteDataという取り出した値を入れる構造体は作っています。)

hostに記載があれば他インスタンス投稿なのでそのホスト名を表示したり、isCatのフラグがあれば名前の後に(Cat)を付加して猫であることをわかるようにしたり、添付ファイルが1つでもあれば(添付あり)と表示するなどしたりなど、簡易的な閲覧であれば十分と思われる情報量を表示しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
type noteData struct {
    offset    string
    timestamp string
    name      string
    username  string
    host      string
    text      string
    attach    string
    id        string
    isCat     bool
}

func pickNote(value []byte) (noteData, error) {
    var note noteData
    var err error
    // 投稿者
    note.name, _ = jsonparser.GetString(value, user, name)

    //投稿者ID
    note.username, err = jsonparser.GetString(value, user, username)
    if err != nil {
        return note, err
    }
    //ホスト名
    note.host, err = jsonparser.GetString(value, user, host)
    if err == nil {
        note.username = note.username + @ + note.host
    }

    // 投稿時刻
    note.timestamp, err = jsonparser.GetString(value, createdAt)
    if err != nil {
        return note, err
    }
    t, _ := time.ParseInLocation(2006-01-02T15:04:05Z, note.timestamp, time.UTC)
    note.timestamp = t.In(time.Local).Format(2006/01/02 15:04:05)

    // 本文
    note.text, _ = jsonparser.GetString(value, text)

    //投稿ID(元投稿)
    note.id, err = jsonparser.GetString(value, id)
    if err != nil {
        return note, err
    }

    //ねこかどうか
    isCat, err := jsonparser.GetBoolean(value, user, isCat)
    if isCat {
        note.name = note.name + (Cat)
    }

    // ファイルが有れば
    filesId, _, _, _ := jsonparser.Get(value, files)
    if len(filesId) != 2 {
        note.attach =    (添付有り)
    }

    return note, nil
}

ちなみに、名前欄を赤色にしたり、添付がある場合に緑、投稿IDを青にしているは、\x1b[35m%s(@%s)\x1b[0m\tといったようなエスケープシーケンスを使って、標準出力側で装飾を指定することで実装しています。(リプライの場合はピンクに)

リプライ

リプライがある場合は、

1
2
2022/12/14 23:55:06 名前(@ID@host)     リプライ元の投稿    (98xxxxxxxx)
    2022/12/14 23:56:38 名前(@ID@host)     リプライ投稿の本文    (添付有り)(98aaaaaaaa)

といったように表示されます。

一見リプライ元の投稿の情報を別に取りに行ってるように見えますが、実際はリプライの投稿のJSON内にreplyという要素があり、その中に通常投稿と同じデータ構造で投稿内容が入っています。なので、

1
2
replyParentValue, _, _, _ := jsonparser.Get(value, reply)
replyParent, err := pickNote(replyParentValue)

と書くだけで、リプライ元の投稿の情報をpickNoteを使って持ってくることが出来るのです。

また、リプライはぶら下がっている印象があるので、noteData構造体にあるoffsetにスペース4つをいれ、行頭に挿入することでぶら下がりを表現しています。

リノート

リノートの表示方法もリプライのリプライ元表示に似ています。リプライがreply要素に投稿が入っていたのと同様に、リノートでもrenote要素に投稿が入っているので、renote要素を取り出したものをpickNoteに渡すことで投稿内容を取得しています。

1
2
renoteValue, _, _, _ := jsonparser.Get(value, renote)
note, err = pickNote(renoteValue)

また、リノートの場合は行頭に[RN]を付加することでリノートであることを表現しています。

その他表示に関わる部分(いつかなにか改善したら追記)

1行の文字数

misskey-cliでコマンドを叩くと、ヘッダ部分の「=======」の長さがコンソールの幅によって変わることがわかります。

この「=======」を表示するのは各機能の関数でfmt.Println内に書かれているprintLine()という関数が表示しています。

このprintLine()は

https://github.com/mikuta0407/misskey-cli/blob/67d17a15d3bff0e7c258f42a61b5d99679e2d164/misskey/misskey.go#L59-L70

でこのように記述しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func printLine() {
    width, _, err := terminal.GetSize(syscall.Stdin)
    if err != nil {
        fmt.Printf(Error : %+v, err)
        os.Exit(1)
    }

    for i := 1; i <= width; i++ {
        fmt.Printf(=)
    }
    fmt.Printf(\n)
}

terminal.GetSize(syscall.Stdin)はターミナルの横幅を取得する関数です。これで貰えた幅の文字数分、「=」を表示することが出来ます。

……実はこれのせいでmisskey-cliはWindows版がありません。WSL1で動くので許して下さい……。

まとめ

やったことをダラダラと書く記事になってしまいましたが、ここまで耐えて読んでくださった方、ありがとうございます。

初めて非身内なアドカレに参加し、あまりにも強い方々の投稿を見て焦りのような緊張を感じつつ書いていました。(その割に執筆中に未実装機能に気づくなどしていましたが・・・)

今年は色んな意味でFediverseが話題となり、Misskeyにもいろんな投稿が流れてTLが賑わっているのを楽しく見ています。今年はMisskeyでもあけおめ投稿してみようかな・・・

そしてTUI版を作ってみようかな・・・(完成するとは言ってない)

ありがとうございました!

Licensed under CC BY-SA 4.0
comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。