🍣

AiScriptでMisskeyのプラグイン開発を始める

2024/12/02に公開

Misskeyはいいぞ

はじめまして。ツッナと申します。Misskey、始めました。

個人的には今後MisskeyがSNSのメインストリームになっていくといいなぁと願っています。リアクションをもらうのも送るのに選ぶのもたのしい。あとあいさつ文化も好き。


ところで。MisskeyにはAiScriptというプログラミング言語が組み込まれています。
この言語を使ってプラグインやウィジェットを自作して、Misskeyをカスタマイズすることが可能です。

参考:AiScript | Misskey Hub

この記事では、AiScriptで「投稿フォームに入力した内容を全削除するプラグイン」と「突然の死ジェネレータプラグイン」を自作した過程で得られた知見や個人的な備忘を紹介していきます。

開発環境・ドキュメント

エディタ

私はコーディングにはVSCode+AiScript公式のVSCode拡張を利用しました。

AiScript公式のVSCode拡張はプラグインのMarketplaceで配布されていないため、[Extensions: Install Extension...]コマンドで検索しても見つかりません。

AiScriptのGithubリポジトリaiscript-dev/aiscript-vscodeHow-to-Installを参照し、releasesから.vsixファイルをダウンロードして手動でインストールします。
手動インストールと聞いて私は少し尻込みしたのですが、実際にはこの.vsixファイルをVSCodeから読み込むだけだったので簡単でした。

拡張機能には、シンタックスハイライトとIntelliSense機能が含まれています。

AiScript拡張のインストール手順

  1. release pageの[Assets]の部分をクリックして開いて、aiscript-vscode-x.x.xx.vsixをダウンロードして任意の場所に保存します。

  2. vscode上で[Extensions]→[...]→[Install from VSIX...]を選択して、ダウンロードした.vsixファイルを選択します。

拡張機能が自動起動するデフォルトの拡張子は.aisです。

実行・デバッグ

私は主にMisskeyの「スクラッチパッド」機能を用いて行いてデバッグや動作確認を行いました。

npmでローカルにAiScriptの実行環境を構築したり、ローカルのMisskeyを構築して動作確認、ということも可能だとは思うのですが、そこまでのことはできていません。


スクラッチパッド

この画面にソースコードを貼り付けて実行(▷)すると、出力欄に結果が表示されます。

スクラッチパッドは、画面左上のMisskeyアイコンをクリックし[ツール]→[スクラッチパッド]を順に選択することで開くことができます。


スクラッチパッドの開き方

その他に手軽にAiScriptを実行できる環境としては、ウィジェットの「AiScriptコンソール」機能もあります。

しかし、スクラッチパッドはソースコードがテキスト欄に一時保存される一方で、AiScriptコンソールは画面をリロードすると内容が消えてしまいます
そのため、スクラッチパッドを利用するほうが安心だと思います。

特に、次の記事で解説する予定のMk:apiのようなMisskeyのAPIを使用する関数を使用する場合、API Console(上画像の[スクラッチパッド]の下にある)やMisskeyのAPIドキュメントを開いて画面を行き来することになります。

その際、エディタから貼り付けたコードを動くように修正し、その修正をエディタに戻す前に誤って画面を更新して消える、といった悲劇が起こりかねないので、基本的に私はスクラッチパッドを使って動作確認とデバッグを行っていました。

ドキュメント

AiScriptの文法や関数について調べる際には、主にGithubの公式ドキュメントを参照しました。

公式ドキュメント以外では、こちらに先達のノウハウが記録されていることが多いと思います。

  • Misskey.io AiScrip部のチャンネルへ投稿されたノート
  • Misskey.io AiScrip部に参加されている方の'ページ'
  • Misskey Playのコード表示(Playはゲーム開始画面でコードを表示できる)
  • ハッシュタグ(#AiScript, #MisskeyPlugin)に投稿されたノート

また、Misskey.io AiScrip部のチャンネルのトップページには、既存の有益な開発関連リンク集がまとめて記載されています(ありがたい)。

その他に、Misskeyの開発に参画されている方が新たにリファレンス作成作業が進行中だったりもするようです。(「まだ作りかけ」とのこと)
https://misskey.io/notes/a161b2yv319s05o6
https://aiscript-dev.github.io/ja/

プラグイン開発

プラグインのインストール

続けて、プラグインの導入方法を紹介します。

インストール方法

プラグインのインストールは「[設定]の[プラグイン]設定に直接AiScriptのコードを貼り付ける手法が主流」だと感じました。
私が開発の際に見た資料から感じた限りで、ではありますが....


Misskey.ioのプラグイン設定画面

念の為に補足すると、公式でプラグインを配布する機能は提供されています

プラグイン・テーマを配布する | Misskey Hub

また、その配布機能とMisskeyのドライブ機能を組み合わせることで、配布サーバなしでプラグインを配布する方法を紹介されている記事もありました。

MisskeyのPluginやThemeをMisskeyの機能のみを活用して配布する #misskey - Qiita

ただやはり個人的には、ソースコードを直接配布するのが主流だと感じました。

それは私が、Misskey.io AiScript部といったMisskeyのチャンネル内の投稿や、ハッシュタグ(#AiScript, #MisskeyPlugin)のノート(Twitterでいうところのツイート)を閲覧していたとき、ノートに直接ソースコードが書き込まれた投稿を多く見かけたためだと思います。

今後、どこかにプラグインが集約されるようなことがあると嬉しいですね。

参考:プラグイン'以外'の開発

AiScriptはプラグインの他に「ウィジェット(ウィジェット専用のエリアに機能を追加できる)」「Play(ゲームなどいろいろ開発できる)」といった機能の開発も可能です。

AiScriptは、Misskeyの以下の箇所で使用できるスクリプト言語です。
・プラグイン
・ウィジェット
 ・ボタン
 ・AiScriptコンソール
 ・AiScript App
・Misskey Play
・スクラッチパッド

引用: AiScript | Misskey Hub

今回作成した「フォームをクリアする」機能と「フォームの文字を突然の死フォーマットで囲う」機能は、どちらも「投稿フォームの内容を書き換える」処理なので、プラグインとして開発しました。

他にも「ノートの内容を書き換える」「ノートを他の何かと連携する」といった機能を作る場合、私はプラグインを第一候補にすると思います。関数の引数として対象のノートやページのオブジェクトを直接受け取れるので。

一方で、「ウィジェット」や「Play」はUIを生成する関数が利用できるので、複数の値を入力したり、複数の手順や準備・設定が必要な処理をまとめるのによいかもしれません。

プラグインをメニューに追加する関数

インストールしたプラグインをユーザが操作可能なメニューとして追加するには、Misskey用に定義されたAiScriptの拡張関数を使用します。

これら拡張関数は「メニューに追加した際の表示名」や「メニューを選択した際に実行する関数」を引数として受け取ります。
「メニューを選択した際に実行する関数」はコールバック関数(関数の引数として渡される関数)であり、その引数は親関数(ここではMisskey用拡張関数)から渡されます。
(ただコールバック関数の呼び出しと引数との関係は使った感じの雰囲気で説明しているので、ご自分でコールバック関数の仕様を調べていただけるとたすかります.....)

AiScript Misskey拡張API リファレンス#プラグイン専用のセクションを確認すると、プラグイン用に7つの関数と1つの設定オブジェクトが用意されていることがわかります。

  1. Plugin:register_post_form_action(title, fn):投稿フォームにアクションを追加する。
  2. Plugin:register_note_action(title, fn):ノートメニューにアクションを追加する
  3. Plugin:register_user_action(title, fn):ユーザメニューにアクションを追加する
  4. Plugin:register_note_view_interruptor(fn):(表示するタイミングで)ノートを書き換える
  5. Plugin:register_note_post_interruptor(fn):(投稿するタイミングで)ノートを書き換える
  6. Plugin:register_page_view_interruptor(fn):Page閲覧時にPageを書き換える
  7. Plugin:open_url(url):URLをブラウザの新しいタブで開く
  8. Plugin:config:プラグインに後から設定できるパラメータの変数

1.~6.の関数では共通して引数にfn(コールバック関数)を受け取ります。
この引数にプラグインとして追加したい関数を渡す(または無名関数を定義して直接処理を記述する)ことで、インストールしたプラグインのメニューと、AiScriptの処理を紐づけます。

私が今回の開発で使用した1.の関数を例に取ると、このようにプラグアイコンの項目がメニューに追加されます。

プラグイン追加で増えたメニュー

また、プラグイン開発に使用できる拡張関数は、プラグイン専用のものだけではなく、ウィジェットやPlayとも共通して利用できる全分野共通関数も定義されています。
こちらは5つの関数が用意されています。

  1. Mk:dialog(title, text, type):ダイアログを表示する
  2. Mk:confirm(title, text, type):確認フォームを表示してOK,キャンセルでそれぞれbooleanの戻り値を得る
  3. Mk:api(endpoint, params, token?):Misskey APIにリクエストを送信します
  4. Mk:save(key, value):任意の値に任意の名前を付けて永続化する
  5. Mk:load(key)Mk:saveで永続化した値を読み取ります

主にこれらの拡張関数と、別途定義されている全分野共通定数(現在のユーザIDやユーザ名など)を使って、AiScriptの処理をMisskeyに追加したり、Misskeyと値の受け渡しを行ったりします。

公式プラグインの例

以上を踏まえて、Misskey Hubのプラグインの例に記載されている「投稿フォームにフグパンチボタンを追加するプラグイン」を見ると、このプラグインが

  1. フォームに機能を追加する関数(Plugin:register_post_form_action())を実行し
  2. その表示名が「フグパンチ」であり
  3. フォームのオブジェクトnoteとそれを書き換える関数rewriteを無名関数@()の引数として受け取り
  4. 続く関数の本体{}中に処理内容を記載しているプラグインである

ということを読み取っていただけるのではないかと思います。

/// @ 0.12.4
### {
  name: "フグパンチボタン"
  version: "0.0.1"
  author: "Misskey Project"
}

Plugin:register_post_form_action('フグパンチ', @(note, rewrite) {
  let fugu = "フグパンチ!!!!🐡( '-' 🐡 )"

  if (note.text.trim() == '') {
    // ノートの中身がない場合はフグパンチに置き換え
    rewrite('text', fugu)
  } else {
    // ノートの中身がある場合は冒頭にフグパンチを追加して改行
    rewrite('text', `{fugu}{Str:lf}{note.text}`)
  }
})

引用:プラグインの作成 | Misskey Hub

そして、このプラグインを[設定]の[プラグイン]画面でソースコードを直接貼り付けてインストールし、追加されたプラグアイコンのメニューから実行する、というのが開発から実行までのざっくりした流れになります。

事例紹介

以降は、私が作成したプラグインを例にとって、気づいたことや備忘を紹介していきます。
断片的な紹介になりますので、気になったところをつまみ読みしていただければと。

プラグイン1:フォームクリア

投稿フォームに記入された内容を全て消去するプラグインです。
Misskeyの投稿フォームは標準では投稿フォームのポップアップを閉じても記入内容が一時保存される仕様だったので試しに作ってみました。

ソースコード

/// @ 0.16.0
### {
    name: "フォームクリア"
    version: "0.0.1"
    author: "ツッナ"
    description: "フォームに入力済みの文字を全て消去します。"
}

Plugin:register_post_form_action('フォームクリア', @(note, rewrite) {
    rewrite('text', '')
})

バージョン指定

一行目の/// @ 0.16.0の部分です。実行するAiScriptのバージョンを指定します。
プラグインとしてインストールする際、この記述がなければインストールに失敗します。

スクラッチパッドで動作しているAiScriptのバージョンを取得するには標準定数のCore:vを利用します。古いバージョンだと空白で配列の値や関数の引数を区切ることができたような違いがあるようです。その記法は現在は廃止されている模様(?)。

print(Core:v)
> 0.19.0

参考:書式 | aiscript/docs/std.md at master | Github

メタデータ

ソースコードの### {}で囲まれた部分のことです。
この部分も記述が無いとプラグインのインストールに失敗します。

### {
    name: "フォームクリア"
    version: "0.0.1"
    author: "ツッナ"
    description: "フォームに入力済みの文字を全て消去します。"
}

メタ情報は以下参考画像のようにプラグイン管理画面の表示に使用されます。

なお、autherversionの内容に制約や規約はいまのところなさそうでした。
(項目がユーザ名と一致しないといけないとか、プラグインのバージョン指定に決まりがあるとか)
もしかしたらベストプラクティスや推奨事項はどこかにあるかもしれません。


プラグイン管理画面

また、descriptionパラメータは改行できない仕様のようでした。
文字列の値を通常の文字列ではなくテンプレートリテラル(`{}`)として、標準定数の改行文字Str:lfを埋め込んだり、\nを挿入したり試行錯誤しましたが、どれもうまく動作しませんでした。

Plugin:register_post_form_action(title, fn)

Plugin:register_post_form_action('フォームクリア', @(note, rewrite) {
    rewrite('text', '')
})

投稿フォームにプラグインのメニューを追加する関数です。
フグパンチボタンの説明と重複しますが、第一引数「'フォームクリア'」は投稿フォームのプラグインメニューに表示される名称です。

@(note, rewrite)はコールバック関数で、noteは投稿フォームのオブジェクト、rewriteは第一引数で受け取った名称のnoteのプロパティ値を第二引数の値に書き換える(組み込み?)関数だと思われます。

noteオブジェクトが持っているプロパティの一覧はMisskey APIのnotes/createでPostするjsonのbodyを参考にすれば大きくは外れていないのではないかなと考えています。
完全なプロパティ一覧はgithubのnotesにあたるモデルを読み解くか、一度自力で受け取った引数のnoteをprintデバッグしてみないとなんとも言えません。

参考:notes/create | Misskey API

プラグイン2:突然の死ジェネレータ

フォームに入力した文字列を突然の死フォーマットで囲うプラグインです。
以前は突然の死ジェネレータさんのサイトを利用させていただいていたのですが、都度ブラウザを開くのが面倒になってきて、自作してみたものです。

本家様ほどの精度はありませんが、手軽に突然の死フォーマットを追記できるようになりました。

ソースコード

/// @ 0.16.0
### {
    name: "突然の死"
    version: "0.0.1"
    author: "ツッナ"
    description: "フォームに入力済みの文字列を突然の死フォーマットで囲います。"
}

@surround_with_symbols(form_text) {
    var contents = []

    // 複数行ある場合に長い方の文字列長を選択するための処理
    let lines = form_text.split(Str:lf)
    var num_of_brackets = -1
    each let line, lines {
        let current = get_num_of_brackets(line)
        num_of_brackets = Math:max(num_of_brackets, current)
        contents.push(`> {line} <`)
    }

    // 突然の死の囲い記号を長い文字列長基準で構成する
    var header = `_人{["人"].repeat(num_of_brackets).join()}人_`
    var footer = ` ̄Y^{["Y^"].repeat(num_of_brackets).join()}Y ̄`
    contents.unshift(header)
    contents.push(footer)

    return contents.join(Str:lf)
}

@get_num_of_brackets(input_line) {
    // strの文字列を10進数でutf-16コード化
    let charcodes = input_line.to_charcode_arr()

    var total_len = 0.0
    each let c, charcodes {
        if (is_multibyte_char(c)) {
            total_len += 1.0
        } else {
            total_len += 0.5
        }
    }

    return Math:trunc(total_len)
}

@is_multibyte_char(charcode) {
    // 半角文字判定
    if (charcode >= 33 && charcode <= 126) { return false }      // 半角英数字の範囲(Unicode: 0x21 から 0x7E)
    if (charcode >= 65377 && charcode <= 65439) { return false } // 半角カナの範囲(Unicode: U+FF61 から U+FF9F)

    // 上記以外(マルチバイト幅の文字)の場合trueを返す
    return true
}

Plugin:register_post_form_action('突然の死', @(note, rewrite) {
    rewrite('text', surround_with_symbols(note.text))
})

調査したこと

突然の死ジェネレータを制作するにあたって、ざっくりと次の点についてAiScriptの仕様を調べる必要があると感じていました。

  • 文字列操作の関数は何が定義されているのか
  • 配列の操作はどう書けばよいのか
  • 半角英数および半角カナを判別する機能は実装できるか
  • 改行文字は何か("\n"で改行されるのか)

それぞれについて個人的な備忘や気付きを順に記載します。

文字列操作の関数は何が定義されているのか

前提として文字を扱う関数は二種類あり「標準関数Str:*」と「string型変数の組み込みプロパティ(組み込み関数)」に分かれます。

この二つは別々のドキュメントに記述されており(std.mdbuildin-prop.md)片方に探している関数がない場合、もう片方のドキュメントも確認したほうがよいと思います。

私はここを把握できていなかったために.lenが言語に組み込まれていないものだと思い込み一人でテンパっていました。(その上半角文字対応のために結局.lenは使っていない....)

ドキュメントを確認すると、文字列操作に必要な関数は大体あると感じたので、このプラグインの開発で使用した関数とその処理を簡単に紹介します。

@(v: str).split(splitter?: str): arr

文字列を splitter がある場所で区切り、配列にしたものを返します。
splitter が与えられなければ一文字づつ区切ります。

引用:@(v: str).split(splitter?: str) | aiscript/docs/buildin-props.md

フォームの文字列全文を格納したform_text変数を改行区切りで分割する処理で使用しました。

let lines = form_text.split(Str:lf)

.split()の引数Str:lfはAiScriptの標準定数の改行コード(LF)です。

参考:#Str:lf | aiscript/docs/std.md

テンプレートリテラル

変数や式を埋め込んだ文字列を作成するためのリテラルです。
全体を` `で囲い、式を埋め込む場所は{ }で囲います。
式の値が文字列でない場合は、Core:to_strと同じ方法で文字列に変換されます。

引用:テンプレートリテラル | aiscript/docs/literals.md

変数の前後に文字を追記するために使用しました。

contents.push(`> {line} <`)
@(v: str).to_charcode_arr(): arr<num>

文字列を UTF-16 コード単位毎に区切り、それぞれUTF-16 コード単位を表す 0 から 65535 までの整数を取得し配列にしたものを返します。
文字列にサロゲートペアが含まれる場合、上位と下位それぞれ孤立サロゲートを返します。

引用:@(v: str).to_charcode_arr() | aiscript/docs/builtin-props.md

フォームのテキスト一行分をutf-16の文字コード(10進数)の数値配列arr<num>にして変数に格納しています。
この後の処理で、配列の各要素をeach let(foreach)で回して、半角文字かどうかを判定しました。

// strの文字列を10進数でutf-16コード化
let charcodes = input_line.to_charcode_arr()

ただ私は'サロゲートペア'や、utf-8やarr<str>を返す以外の点で、他の類似関数と何が違うのかうまく理解できなかったので、ざっくり「文字列の各文字をutf-16のコードに変換し、10進数表記の配列arr<num>で返す関数」くらいの感覚で使用しています。

スクラッチパッドで実行した例を示してお茶を濁そうと思います。
(何故か@(v: num).to_hex()がスクラッチパッドで使えなかった....)

let charcode_arr = "突然の死".to_charcode_arr()
print(`charcode_arrの型: {Core:type(charcode_arr)}`)
print("")
each let c, charcode_arr {
  print(`{Str:from_codepoint(c)}: [値: {c}] [型: {Core:type(c)}]`)
}

// 出力
> charcode_arrの型: arr
>: [: 31361] [: num]
>: [: 28982] [: num]
>: [: 12398] [: num]
>: [: 27515] [: num]
配列の操作はどう書けばよいのか

基本的な配列操作はこの部分で行っているので、使用している関数を列挙して引用します。

// 突然の死の囲い記号を長い文字列長基準で構成する
var header = `_人{["人"].repeat(num_of_brackets).join()}人_`
var footer = ` ̄Y^{["Y^"].repeat(num_of_brackets).join()}Y ̄`
contents.unshift(header)
contents.push(footer)

@(v: arr).push(i: value): null
【この操作は配列を書き換えます】
配列の最後に要素を追加します。

@(v: arr).unshift(i: value): null
【この操作は配列を書き換えます】
配列の先頭に要素を追加します。

@(v: arr).join(joiner?: str): str
文字列の配列を結合して一つの文字列として返します。

@(v: arr).repeat(times: num): arr
配列を times 回繰り返した配列を作成します。
arr.copy同様シャローコピーであり、配列やオブジェクトの参照は維持されます。
times には0以上の整数値を指定します。それ以外ではエラーになります。

配列 | aiscript/docs/builtin-props.md

こちらも、テンプレートリテラルの中で使用している組み込みプロパティ(関数)の戻り値をスクラッチパッドで出力したものを説明とさせていただければと。

let num_of_brackets = 5
let r = ["人"].repeat(num_of_brackets)
print(`[値: {r}] [型: {Core:type(r)}]`)
r.map(@(s) { print(`[値: {s}] [型: {Core:type(s)}]`) })
// 出力
> [: [ "人", "人", "人", "人", "人" ]] [: arr]
> [:] [: str]
> [:] [: str]
> [:] [: str]
> [:] [: str]
> [:] [: str]

let header = ["人"].repeat(num_of_brackets).join()
print(`[値: {header}] [型: {Core:type(header)}]`)
// 出力
> [: 人人人人人] [: str]
半角英数および半角カナを判別する機能は実装できるか

可能でした。ただし、10進数で文字コードの範囲を判定する必要があったので、大小比較と論理演算で条件文を記述することになりました。
なお、現時点ではAiScriptはビット演算系の関数や演算子を定義していないようです。

@is_multibyte_char(charcode) {
    // 半角文字判定
    if (charcode >= 33 && charcode <= 126) { return false }      // 半角英数字の範囲(Unicode: 0x21 から 0x7E)
    if (charcode >= 65377 && charcode <= 65439) { return false } // 半角カナの範囲(Unicode: U+FF61 から U+FF9F)

    // 上記以外(マルチバイト幅の文字)の場合trueを返す
    return true
}
改行文字は何か("\n"で改行されるのか)

Str:lfだけが改行文字として扱われます。テンプレートリテラルに"\n"を埋め込んでも改行されずそのまま出力されます。

なお、スクラッチパッドで次のような記述を実行しても、[出力]欄では空白1つ分しか出力されませんが、UI上では正常に改行が追加されます。

print(`1{Str:lf}2{Str:lf}3`)
// 出力欄ではこうなる
> 1 2 3
// 実際のMisskey上(あるいはUi:*などを使用すると)改行して表示される
1
2
3

おわりに

長文・散文にもかかわらず、最後までお読みいただきありがとうございました。
この記事が少しでもAiScriptやMisskeyプラグインが気になっている方のお役に立てば幸いです。

次は「ノートをページに追加するプラグイン」についての記事の作成を予定しています。
Mk:apiのレスポンスの扱い、Misskey APIのドキュメント(api-doc)の読み方、API Consoleの使い方などについて紹介できればなと考えております。

それでは改めて、最後までお付き合いいただきありがとうございました!

Discussion