GNU social
Tool
外部ツールがいくつかある。
- GitHub - benediktg/gnu-social-export: Export list of followed accounts from GNU social to import it at Mastodon: GNU socialのフォローリストをエクスポートする。
Linkage
- gnusrss – Publicando feeds en GNU Social – Elbinario: RSSをGNU socialに自動投稿するPythonプログラム。
- WP-GNU social: Descentralizando los comentarios – Elbinario: WordPressの投稿をGNU socialでも投稿し、それへの返信をWordPressにも反映する。
API
GNU socialのWeb APIについては,フッターの [Help](doc/help), [About](doc/about), [TOS](doc/tos) などを選択して遷移できるDOCS画面の [API](doc/api) (Api - GNU social JP) を選択することで表示できる。
ソースコード上はdoc-src (v2.0.2 - NotABug.org: Free code hosting) 配下にAPI文書が格納されている。
AtomPubとTwitter互換APIの2種類のAPIを利用可能。Twitter APIのほうがシンプルだが、AtomPubだとリッチテキストが使用可能など、機能に若干の違いがある。
以下の記事も参考になる。
API末尾の拡張子atom/xml/jsonに応じた形式の応答をサーバーが返す模様。
Twitter API
- Twitter-compatible API - I ask questions: QvitterのREADMEに記載のあったTwitter互換APIの説明文書。
- Standard v1.1 | Docs | Twitter Developer Platform: X/Twitterの公式文書。
GNU socialのTwitter APIは完全互換ではないので注意が必要。
Post
wget -O - \
    --http-user=vaginaplant \
    --http-passwd=XXXXXX \
    --post-data='status=LGBTPZN' \
    https://freezepeach.xyz/api/statuses/update.json
curl -u username:password -d "status=status" https://domain/api/statuses/update.json
添付ファイルの例。
curl -u username:password \ -F "media=@image.jpg" \ -F "status=post message" \ https://domain.jp/api/statuses/update.json
返信する場合、リクエストボディーにin_reply_to_status_idで返信先投稿IDを指定する。Twitter APIだと、本文に@メンションが必須だが、GSの場合、メンションは任意の模様。ただ、他の実装だと必須のものもあったりする。あるほうが安定するかもしれない。
Get
JSONの配列で応答が返ってくる。
curl $HOST/api/statuses/user_timeline/test.json
 [
   {
     "text": "QSettings",
     "truncated": false,
     "created_at": "Thu Jan 04 11:51:11 +0900 2024",
     "in_reply_to_status_id": null,
     "uri": "tag:gnusocial.jp,2024-01-04:noticeId=5026657:objectType=note",
     "source": "",
     "source_link": null,
     "id": 5026657,
     "in_reply_to_user_id": null,
     "in_reply_to_screen_name": null,
     "geo": null,
     "user": {
       "id": 2,
       "name": "test",
       "screen_name": "test",
       "location": "Japan",
       "description": "gnusocial.jpのテストアカウントです。\r\nID/PW=test/666666。\r\n投稿にはメール登録が必要です。基本は閲覧用です。",
       "profile_image_url": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-stream.png",
       "profile_image_url_https": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-stream.png",
       "profile_image_url_profile_size": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-profile.png",
       "profile_image_url_original": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-profile.png",
       "groups_count": 1,
       "linkcolor": false,
       "backgroundcolor": false,
       "url": "https://example.com",
       "protected": false,
       "followers_count": 15,
       "friends_count": 1,
       "created_at": "Mon Jul 18 13:10:39 +0900 2022",
       "utc_offset": "32400",
       "time_zone": "Asia/Tokyo",
       "statuses_count": 35,
       "following": false,
       "statusnet_blocking": false,
       "notifications": false,
       "statusnet_profile_url": "https://gnusocial.jp/test",
       "cover_photo": false,
       "background_image": false,
       "profile_link_color": false,
       "profile_background_color": false,
       "profile_banner_url": false,
       "is_local": true,
       "is_silenced": false,
       "rights": {
         "delete_user": false,
         "delete_others_notice": false,
         "silence": false,
         "sandbox": false
       },
       "is_sandboxed": false,
       "ostatus_uri": "https://gnusocial.jp/index.php/user/2",
       "favourites_count": 0
     },
     "statusnet_html": "QSettings",
     "statusnet_conversation_id": 2539347,
     "statusnet_in_groups": false,
     "external_url": "https://gnusocial.jp/notice/5026657",
     "in_reply_to_profileurl": null,
     "in_reply_to_ostatus_uri": null,
     "attentions": [],
     "fave_num": 0,
     "repeat_num": 0,
     "is_post_verb": true,
     "is_local": true,
     "favorited": false,
     "repeated": false
   },
   {
     "text": "test 2",
     "truncated": false,
     "created_at": "Thu Jan 04 10:41:35 +0900 2024",
     "in_reply_to_status_id": null,
     "uri": "tag:gnusocial.jp,2024-01-04:noticeId=5026119:objectType=note",
     "source": "",
     "source_link": null,
     "id": 5026119,
     "in_reply_to_user_id": null,
     "in_reply_to_screen_name": null,
     "geo": null,
     "user": {
       "id": 2,
       "name": "test",
       "screen_name": "test",
       "location": "Japan",
       "description": "gnusocial.jpのテストアカウントです。\r\nID/PW=test/666666。\r\n投稿にはメール登録が必要です。基本は閲覧用です。",
       "profile_image_url": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-stream.png",
       "profile_image_url_https": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-stream.png",
       "profile_image_url_profile_size": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-profile.png",
       "profile_image_url_original": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-profile.png",
       "groups_count": 1,
       "linkcolor": false,
       "backgroundcolor": false,
       "url": "https://example.com",
       "protected": false,
       "followers_count": 15,
       "friends_count": 1,
       "created_at": "Mon Jul 18 13:10:39 +0900 2022",
       "utc_offset": "32400",
       "time_zone": "Asia/Tokyo",
       "statuses_count": 35,
       "following": false,
       "statusnet_blocking": false,
       "notifications": false,
       "statusnet_profile_url": "https://gnusocial.jp/test",
       "cover_photo": false,
       "background_image": false,
       "profile_link_color": false,
       "profile_background_color": false,
       "profile_banner_url": false,
       "is_local": true,
       "is_silenced": false,
       "rights": {
         "delete_user": false,
         "delete_others_notice": false,
         "silence": false,
         "sandbox": false
       },
       "is_sandboxed": false,
       "ostatus_uri": "https://gnusocial.jp/index.php/user/2",
       "favourites_count": 0
     },
     "statusnet_html": "test 2",
     "statusnet_conversation_id": 2539084,
     "statusnet_in_groups": false,
     "external_url": "https://gnusocial.jp/notice/5026119",
     "in_reply_to_profileurl": null,
     "in_reply_to_ostatus_uri": null,
     "attentions": [],
     "fave_num": 0,
     "repeat_num": 0,
     "is_post_verb": true,
     "is_local": true,
     "favorited": false,
     "repeated": false
   },
   {
     "text": "test (https://gnusocial.jp/test) started following めうるみ (とけた) (https://mewl.me/@mewl).",
     "truncated": false,
     "created_at": "Thu Sep 08 08:32:22 +0900 2022",
     "in_reply_to_status_id": null,
     "uri": "tag:gnusocial.jp,2022-09-07:subscription:2:person:9308:2022-09-08T08:32:22+09:00",
     "source": "activity",
     "source_link": null,
     "id": 274143,
     "in_reply_to_user_id": null,
     "in_reply_to_screen_name": null,
     "geo": null,
     "user": {
       "id": 2,
       "name": "test",
       "screen_name": "test",
       "location": "Japan",
       "description": "gnusocial.jpのテストアカウントです。\r\nID/PW=test/666666。\r\n投稿にはメール登録が必要です。基本は閲覧用です。",
       "profile_image_url": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-stream.png",
       "profile_image_url_https": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-stream.png",
       "profile_image_url_profile_size": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-profile.png",
       "profile_image_url_original": "https://gnusocial.jp/theme/gnusocialjp/default-avatar-profile.png",
       "groups_count": 1,
       "linkcolor": false,
       "backgroundcolor": false,
       "url": "https://example.com",
       "protected": false,
       "followers_count": 15,
       "friends_count": 1,
       "created_at": "Mon Jul 18 13:10:39 +0900 2022",
       "utc_offset": "32400",
       "time_zone": "Asia/Tokyo",
       "statuses_count": 35,
       "following": false,
       "statusnet_blocking": false,
       "notifications": false,
       "statusnet_profile_url": "https://gnusocial.jp/test",
       "cover_photo": false,
       "background_image": false,
       "profile_link_color": false,
       "profile_background_color": false,
       "profile_banner_url": false,
       "is_local": true,
       "is_silenced": false,
       "rights": {
         "delete_user": false,
         "delete_others_notice": false,
         "silence": false,
         "sandbox": false
       },
       "is_sandboxed": false,
       "ostatus_uri": "https://gnusocial.jp/index.php/user/2",
       "favourites_count": 0
     },
     "statusnet_html": "test started following めうるみ (とけた).",
     "statusnet_conversation_id": 154562,
     "statusnet_in_groups": false,
     "external_url": "https://gnusocial.jp/notice/274143",
     "in_reply_to_profileurl": null,
     "in_reply_to_ostatus_uri": null,
     "attentions": [],
     "fave_num": 0,
     "repeat_num": 0,
     "is_post_verb": false,
     "is_local": true,
     "favorited": false,
     "repeated": false
   }
 ]
Develop
Config
「lib/util/default.php」にデフォルト設定一覧がある。
ここの設定項目は、 common_config(key1, key2)で参照できる。
Structure
Next
今後採用予定の技術を検討する。
PHP v7.4は当分維持したい。これで動作するバージョンを選ぶ。
基本方針。
- 単独利用可能。
- 普及。
単独利用可能で、普及している、枯れた技術を採用したい。おまけでGPL系のライセンスだとなおよし。
- Smarty: テンプレートエンジン。いろいろ検討して、Twigよりも早く普及している。GNU social自体と誕生も近い。
- Phinx?: DBのマイグレーションツール。なくてもいいかもしれない。
Smartyは導入したいかな。
Version
lib/util/framwork.phpがGNU social全体の初期化ファイルになっており、ここにバージョン番号も記載する。
自分が保守を始める直前のコミットの [before-gnusocialjp] のタグをつけて明示する。
Basic
バグ修正などで、通信と実コードの対応関係を特定する手順があるので整理する。
まず、GNU socialのサイトにアクセスすると、以下のpublic/.htaccessの内容に従って、URLがindex.php?p=<path>の形式に書き換えられる。
  <RewriteCond %{REQUEST_FILENAME} !-f
  RewriteCond %{REQUEST_FILENAME} !-d
  RewriteRule (.*) index.php?p=$1 [L,QSA]
lib/router.php: ルーティング処理 (URLによる挙動の変更) 。ここで<path>と呼び出される処理 (action) が対応づけられている。
index.php内のmain関数でcall_user_func内で最終的にパスに対応するactionクラスのhandleを呼び出しており、このhandleの連鎖で処理が進む。
actionはactions配下にaction名.phpの形式で格納されている。
こういう風に見ると、ソースコードの該当処理を探しやすい。
例えば、グループを新規作成する画面は、POST group/newの通信を行う。これに対応づくactionはnewgroupとなっている。
実際、actions/newgroup.phpというファイルが存在する。この中にdoPost() があり、これで処理している。
このような流れで、だいたい行っている処理の場所を特定できる。
API追加
lib/util/router.phpのinitializeや、プラグインの場合OnInitializeなどでルーティングの設定がされているので、これを追加する。
--- a/lib/util/router.php
+++ b/lib/util/router.php
@@ -574,9 +574,13 @@ class Router
                         ['action' => 'ApiAccountRateLimitStatus'],
                         ['format' => '(xml|json)']);
 
-            $m->connect('api/account/delete.:format',
+            $m->connect('api/account/delete/:id.:format',
                         ['action' => 'ApiAccountDelete'],
-                        ['format' => '(xml|json)']);
+                        ['id' => Nickname::INPUT_FMT,
+                         'format' => '(xml|json)']);
+            $m->connect('api/account/delete.:format',
+                         ['action' => 'ApiAccountDelete'],
+                         ['format' => '(xml|json)']);
actionにaction/配下の相当するクラスで実際のリクエストを処理する。
router内の:id.:formatなどがうまくURLの構成要素になっており、ここを引数として使えるようになっている。
Action
URLMapper.connectで割り当てられたactionクラスは、最終的にindex.php mainでexecuteされることで、一連の処理が始まる。
Action.run-execute-prepare-handle-flushの一連の処理が呼ばれて、該当URLのリクエストが処理・応答される。
Register
- main/register
- api/account/register.:format
DB
DB関係のコードはclassesディレクトリーに配置する。DB操作、テーブルに対応しているクラスはclasses.Managed_DataObjectの継承が基本。
getKVのstaticメソッドで、主キーから行 (レコード) を取得する。
Request
HTTP関係の処理はHTTPClientで行う。
plugins/LinkbackPlugin.phpの例。
        $payload = array(
            'source' => $source,
            'target' => $url
        );
        
        $request = HTTPClient::start();
        try {
            $response = $request->post($endpoint,
                array(
                    'Content-type: application/x-www-form-urlencoded',
                    'Accept: application/json'
                ),
                $payload
            );
            if(!in_array($response->getStatus(), array(200,201,202))) {
                common_log(LOG_WARNING,
                           "Webmention request failed for '$url' ($endpoint)");
            }
        } catch (Exception $e) {
            common_log(LOG_WARNING, "Webmention request failed for '{$url}' ({$endpoint}): {$e->getMessage()}");
        }
startで生成して、ヘッダーとペイロードを設定するだけ。
startは互換用なのでnew HTTPClient()で問題ない。
基本はtryの中で実行する。たぶん、200系以外はExceptionになる。
Log
lib/util/util.phpにログなどの共通関数が存在する。デバッグに役立つ。
- common_log
- common_debug: common_log(DEBUG, )。基本はこれを使う。
- common_log_objstring: DB_DataObjectを文字列に変換。これとcommon_debugを併用する感じ。
- _ve: var_exportのラッパー。オブジェクトの文字化に便利。
スタックトレース: common_debug(_ve(debug_backtrace()));
ログ出力の設定をしておく。以下の構文などでログが出力される。
common_debug(str)
comon_logというのがあるが、これはcommon_log(LOG_INRO, str)のような形で使う。長い。
Test
PHPUnitのテスト記述時のコツがある。
<?php declare(strict_types=1);
use PHPUnit\Framework\TestCase;
define('GNUSOCIAL', true);
define('INSTALLDIR', '.');
set_include_path('../../');
require_once(INSTALLDIR . '/lib/util/framework.php');
final class ApiAccountRegisterActionTest extends TestCase
{
    private $instance;
    protected function setUp(): void
    {
        $GLOBALS['config'] = [];
        $this->instance = new ApiAccountRegisterAction;
    }
    public function testHandle(): void
    {
        $GLOBALS['config']['site'] = ['closed' => true];
        $this->expectExceptionMessage('Registration not allowed.');
        $mock = $this->createMock('ApiAccountRegisterAction');
        $mock->expects($this->any())
            ->method('clientError')
            ->willReturnCallback(function(
                string $msg, int $code = 400, ?string $format = null) {
                throw new \Exception($msg, $code, $format);
        });
        $this->doMethod($mock, 'handle', []);
    }
    /**
     * privateメソッドを実行する.
     * @param object $object 対象オブジェクト。
     * @param string $methodName privateメソッドの名前。
     * @param array $param privateメソッドに渡す引数。
     * @return mixed 実行結果。
     * @throws \ReflectionException 引数のクラスがない場合に発生。
     */
    private function doMethod(object $object, string $methodName, array $param): object
    {
        // テスト対象のクラスをnewする.
        $controller = $object;
        // ReflectionClassをテスト対象のクラスをもとに作る.
        $reflection = new \ReflectionClass($controller);
        // メソッドを取得する.
        $method = $reflection->getMethod($methodName);
        // アクセス許可をする.
        $method->setAccessible(true);
        // メソッドを実行して返却値をそのまま返す.
        return $method->invokeArgs($controller, $param);
    }
}
ポイントは以下3点。
- 冒頭のdefineからrequire_onceの部分でライブラリーのオートロード類を設定する。GNUSOCIALのマクロがないと冒頭のexitで終わるので定義必要。
- テスト対象インスタンス生成時に$configを参照していることが多いので、setUpでダミーで定義しておく。
- clientErrorでexitで中断することが多いので、試験できるようにmockでclientErrorを例外を出す別の関数に置換しておく。これで試験できる。
Plugin
DOCUMENTAION/DEVELOPERS/Pluginsに一連の資料がある。
README.md
プラグインはlib/util/modules/Pluginsクラスを継承することで、GNU socialと連携する。
この継承クラスは、イベント発生時に呼ばれる標準的な命名のメソッド (イベントハンドラー) を持つ。具体的にはonXの形式。XはEVENTS.txtで一覧化されるイベント名がが入る。そして、定義済みの引数を持つ。
例えば以下のようになる。
public function onSomeEvent($paramA, &$paramB): bool
{
    if ($paramA == 'jed') {
        throw new Exception(sprintf(_m("Invalid parameter %s"), $paramA));
    }
    $paramB = 'spock';
    return true;
}
Event Handlers
イベントハンドラーは論理値を返す必要がある。
- false: 他の同じ名前のイベントハンドラーはスキップされる。既存のイベントハンドラーの動きの置換に役立つ。
- true: 処理継続。イベント処理の追加・拡張となる。
例外を投げると、止まる。
Installation
プラグインの有効化には、SmplePluginの場合、以下のコードをconfig.phpに記述する。
addPlugin('Sample');
サードパーティープラグインはlocal/plugins/{$name}/{$pluginclass}.phpに格納する必要がある。$nameはSample、$pluginclassはSamplePlugin。
同梱プラグインはpluginsディレクトリーにある。
Plugin Configuration
プラグインはaddPlugin時にオプション引数で設定できる。
addPlugin('Sample', ['attr1' => 'foo', 'attr2' => 'bar']);
class SamplePlugin extends Plugin
{
    public $attr1 = null;
    public $attr2 = null;
}
引数のキーがそのままプロパティーになる。
Initialization
Pluginクラスのメソッドをオーバーライドする形でいくつかの処理を行っていく。
initializeで初期化する。リモートサーバー接続やパス作成などを行うのに役立つ。
Clean Up
clieanupでリモートサーバーからの切断、一時ファイルの削除などのタイミング。
Database schema setup
プラグインは自分のDBテーブルを保有できる。ensureTableメソッドでテーブル構造や可用性を確認できる。
デフォルトで、スキーマはGNU socialの実行時 (Webページの表示時) にチェックされる。管理者はcheckschema.phpスクリプトの実行で、チェックしたり、性能を向上できる。このスクリプトはプラグインの設置や更新後に実行する必要がある。
例えば、以下のようなコードで、テーブルの保証ができる。
public function onCheckSchema(): bool
{
    $schema = Schema::get();
    // '''For storing user-submitted flags on profiles'''
    $schema->ensureTable('user_greeting_count',[
            new ColumnDef('user_id', 'integer', null, true, 'PRI'),
            new ColumnDef('greeting_count', 'integer')
        ]
    );
    return true;
}
引数1でテーブル名、引数2の配列で列定義の模様。引数2はモデルでstatic関数にしたほうがいいかも。
通常のプラグインは外部プラグインが必要なことが多い。onAutolaodメソッドは関連ファイルを受け取れる。
public function onAutoload($cls): bool
{
    $dir = __DIR__;
    switch ($cls)
    {
        case 'HelloAction':
            include_once $dir . '/' . strtolower(mb_substr($cls, 0, -6)) . '.php';
            return false;
        case 'User_greeting_count':
            include_once $dir . '/'.$cls.'.php';
            return false;
        default:
            return true;
    }
}
注意すべき点として、onAutoloadメソッドはPluginをオーバーロードしている全クラスから呼ばれる。だから他のプラグインを読み込めるようにdefaultでreturn trueを必ずしよう。
Map URLs to actions
onRouterInitializedメソッドはプラグインにURLを割り当てる。いわゆるルーティング。action='foobar'の場合、FoobarActionのハンドラークラスを呼び出す。
public function onRouterInitialized($m): bool
{
    $m->connect('main/hello',
                ['action' => 'hello']);
    return true;
}
イベントハンドラーで、制限なしにデフォルトUIを修正できる。
onEndPrimaryNavメソッドで、メインメニューにメニュー項目を追加できる。
Action Class
Actionクラスは出力メソッドと同様にフック用のイベントのセットを提供する。
public function onEndPrimaryNav($action): bool
{
    // '''common_local_url()''' gets the correct URL for the action name we provide
    $action->menuItem(common_local_url('hello'),
                      _m('Hello'), _m('A warm greeting'), false, 'nav_hello');
    return true;
}
public function onPluginVersion(&$versions): bool
{
    $versions[] = [
        'name' => 'Sample',
        'version' => GNUSOCIAL_VERSION,
        'author' => 'Brion Vibber, Evan Prodromou',
        'homepage' => 'http://example.org/plugin',
        'rawdescription' =>
        _m('A sample plugin to show basics of development for new hackers.')
    ];
    return true;
}
hello.php
hello.phpはユーザーに挨拶文を表示するシンプルなプラグイン。Actionクラスによる出力のサンプル。
Take arguments for running
public function prepare(array $args = []): bool
{
    parent::prepare($args);
    $this->user = common_current_user();
    if (!empty($this->user)) {
        $this->gc = User_greeting_count::inc($this->user->id);
    }
    return true;
}
prepareメソッドは最初に呼ばれ、actionクラスは引数を得る。DBから必要なデータも取得する。
デフォルトの引数処理のため、Actionクラスは最初にparent::prpare($args) を呼ぶべき。
Handle request
public function handle(): void
{
    parent::handle();
    $this->showPage();
}
handleメソッドはリクエストの処理のメイン処理。ほとんどの前処理はprepareメソッドで行っておくべき。handleは単にデータ表示だけなどシンプルにしておいた方がいい。
Title of this page
public function title(): string
{
    if (empty($this->user)) {
        return _m('Hello');
    } else {
        return sprintf(_m('Hello, %s'), $this->user->nickname);
    }
}
titleメソッドでカスタムタイトルを上書き表示できる。
Show content in the content area
public function showContent(): void
{
    if (empty($this->user)) {
        $this->element('p', ['class' => 'greeting'],
                       _m('Hello, stranger!'));
    } else {
        $this->element('p', ['class' => 'greeting'],
                       sprintf(_m('Hello, %s'), $this->user->nickname));
        $this->element('p', ['class' => 'greeting_count'],
                       sprintf(_m('I have greeted you %d time.',
                                  'I have greeted you %d times.',
                                  $this->gc->greeting_count),
                                  $this->gc->greeting_count));
    }
}
デフォルトのGNU socialは大量の装飾がある。メニュー、ロゴ、タブなど。このメソッドはコンテントエリアの表示に使う。
Return true if read only.
public function isReadOnly($args): bool
{
    return false;
}
いくつかのアクションはDBから読み取り専用となる。単純なデータベースのロードバランサーは設定でみらーできる。
デフォルトでDBの一貫性の問題回避のためfalseになっている。ただ、パフォーマンス工場のために変更も検討できる。



