2024-04-02 / @syui

bluesky , atproto

blueskyのコメントシステムを作ってみた

この前、blueskyで特定の投稿に返信することで反映されるコメントシステムを作ってみました。

どういうふうに実現しているのかというと、結構複雑ですが、簡単に説明すると、私は以前からbotを動かしていて、そのついでに返信くらいは取得することができるので、新しくopenapiを追加してそこにコメント情報を登録し、このapiから取得する情報でwebページを生成します。

簡単に概要を見ていくとこんな感じ。

if uri_root == &manga_uri {
    println!("manga_uri:{}", manga_uri);
    let output = Command::new(data_scpt(&"ai"))
        .arg(&"atproto").arg(&"manga")
        .arg(&handle)
        .arg(&did)
        .arg(&cid)
        .arg(&uri)
        .arg(&cid_root)
        .arg(&uri_root)
        .arg(&host)
        .arg(&avatar)
        .arg(&prompt_chat)
        .output()
        .expect("zsh");
    let d = String::from_utf8_lossy(&output.stdout);
    let d = d.to_string();
    let text_limit = c_char(d);
    let str_rep = reply::post_request(
            text_limit.to_string(),
            cid.to_string(),
            uri.to_string(),
            cid_root.to_string(),
            uri_root.to_string(),
            )
        .await;
    println!("{}", str_rep);
    w_cid(cid.to_string(), log_file(&"n1"), true);
}
function manga_text() {
	repo=$did
	collection=app.bsky.feed.post
	url="https://$host/xrpc/com.atproto.repo.getRecord?repo=$repo&collection=$collection&rkey=$rkey&cid=$cid"
	export text=`curl -sL $url|jq -r .value.text`
}

function manga_add() {

	aid=2

	api=https://api.syui.ai
	avatar=$com_option
	text=$com_option_sub_all
	export rkey=`echo $uri|cut -d / -f 5`

	bsky_url="https://bsky.app/profile/$did/post/$rkey"
	if [ "$host" = "syu.is" ];then
		bsky_url="https://web.syu.is/profile/$did/post/$rkey"
	fi

	manga_text

	j="{\"owner\":$aid, \"password\":\"$pass\"}"
	export mid=`curl -X POST -H "Content-Type: application/json" -d $j -sL $api/mas|jq -r .id`

	j="{\"updated_at\":\"$date_iso\", \"token\":\"$token\", \"did\":\"$did\", \"cid\":\"$cid\", \"uri\":\"$uri\", \"rkey\":\"$rkey\", \"bsky_url\":\"$bsky_url\", \"avatar\":\"$avatar\", \"handle\":\"$handle\", \"text\": \"$text\"}"
	tmp=`curl -X PATCH -H "Content-Type: application/json" -d $j -sL $api/mas/$mid`

    echo thx
}
"/mas": {
    "get": {
        "tags": [
            "Ma"
        ],
        "summary": "List Mas",
        "description": "List Mas.",
        "operationId": "listMa",
        "parameters": [
        {
            "name": "page",
            "in": "query",
            "description": "what page to render",
            "schema": {
                "type": "integer",
                "minimum": 1
            }
        },
        {
            "name": "itemsPerPage",
            "in": "query",
            "description": "item count to render per page",
            "schema": {
                "type": "integer",
                "maximum": 5000,
                "minimum": 1
            }
        }
        ],
        "responses": {
            "200": {
                "description": "result Ma list",
                "content": {
                    "application/json": {
                        "schema": {
                            "type": "array",
                            "items": {
                                "$ref": "#/components/schemas/MaList"
                            }
                        }
                    }
                }
            },
            "400": {
                "$ref": "#/components/responses/400"
            },
            "404": {
                "$ref": "#/components/responses/404"
            },
            "409": {
                "$ref": "#/components/responses/409"
            },
            "500": {
                "$ref": "#/components/responses/500"
            }
        }
    },
        "post": {
            "tags": [
                "Ma"
            ],
            "summary": "Create a new Ma",
            "description": "Creates a new Ma and persists it to storage.",
            "operationId": "createMa",
            "requestBody": {
                "description": "Ma to create",
                "content": {
                    "application/json": {
                        "schema": {
                            "type": "object",
                            "properties": {
                                "password": {
                                    "type": "string"
                                },
                                "token": {
                                    "type": "string"
                                },
                                "limit": {
                                    "type": "boolean"
                                },
                                "count": {
                                    "type": "integer"
                                },
                                "handle": {
                                    "type": "string"
                                },
                                "text": {
                                    "type": "string"
                                },
                                "did": {
                                    "type": "string"
                                },
                                "avatar": {
                                    "type": "string"
                                },
                                "cid": {
                                    "type": "string"
                                },
                                "uri": {
                                    "type": "string"
                                },
                                "rkey": {
                                    "type": "string"
                                },
                                "bsky_url": {
                                    "type": "string"
                                },
                                "updated_at": {
                                    "type": "string",
                                    "format": "date-time"
                                },
                                "created_at": {
                                    "type": "string",
                                    "format": "date-time"
                                },
                                "owner": {
                                    "type": "integer"
                                }
                            },
                            "required": [
                                "password",
                            "owner"
                            ]
                        }
                    }
                },
                "required": true
            },
            "responses": {
                "200": {
                    "description": "Ma created",
                    "content": {
                        "application/json": {
                            "schema": {
                                "$ref": "#/components/schemas/MaCreate"
                            }
                        }
                    }
                },
                "400": {
                    "$ref": "#/components/responses/400"
                },
                "409": {
                    "$ref": "#/components/responses/409"
                },
                "500": {
                    "$ref": "#/components/responses/500"
                }
            }
        }
}
<div class="bsky_comment" v-if="comment_open == false">
			<span class="comment">
				<p class="comment-body" v-if="comment_first">
					<img :src="'/icon/' + comment_first.did.replace('did:plc:', '') + '.jpg'" v-if="comment_first.avatar" class="comment"/>  <span class="comment-time" v-if="comment_first.updated_at"><a :href="comment_first.bsky_url">{{ moment(comment_first.updated_at) }}</a></span> <span class="comment-handle" v-if="comment_first.handle"><a :href="'https://' + comment_first.bsky_url.split('/').slice(2,5).join('/')">@{{ comment_first.handle }}</a></span>
				<span class="comment-text" v-if="comment_first.text">{{ comment_first.text }}</span>
			</p>
		</span>
		<div class="comment_open">
			<p>
				<a :href="comment_first.bsky_url" target="_blank">post</a>
			</p>
			<p>
				<button class="comment_open" v-on:click="comment_open = !comment_open"><i class="fa-solid fa-chevron-down"></i></button>
			</p>
		</div>
</div>
<div class="bsky_comment" v-else>
		<span v-for="i in api_json.data" class="comment">
			<p class="comment-body" v-if="i">
				{{ axios_check('/icon/' + i.did.replace('did:plc:', '') + '.jpg') }}
				<img :src="'/icon/' + i.did.replace('did:plc:', '') + '.jpg'" v-if="url_check" class="comment"/><img :src="i.avatar" v-else-if="i.avatar" class="comment"/>  <span class="comment-time" v-if="i.updated_at"><a :href="i.bsky_url">{{ moment(i.updated_at) }}</a></span> <span class="comment-handle" v-if="i.handle"><a :href="'https://' + i.bsky_url.split('/').slice(2,5).join('/')">@{{ i.handle }}</a></span>
			<span class="comment-text" v-if="i.text">{{ i.text }}</span>
		</p>
	</span>
	<div class="comment_open"><button class="comment_open" v-on:click="comment_open = !comment_open"><i class="fa-solid fa-chevron-up"></i></button></div>
</div>

blueskyのapiは、いくつか認証不要のものがありますが、それではavatarとかreplyとかthreadとかを取れません。したがって、loginする必要があります。

また、avatarはリンク切れを起こす可能性が非常に高いので、downloadしたものを参照する必要があります。これはactivitypubとかと同じですね。

axiosでlocal-fileを確認して、それがない場合のみapi-linkを使用します。

methods: {
	axios_check(url) {
		axios.get(url)
		.catch(error => {
			this.url_check = false;
		});
	}
}

次に削除に対応する方法ですが、これにはいくつか手段があります。ただし、どちらも非常に負荷が高いものになります。前者は都度確認する方法、後者は定期的に確認する方法です。前者のほうが遅延、負荷が高く、後者は緩やかです。ですが、その処理は後者のほうが面倒になります。

前者はcom.atproto.repo.getRecordを叩いて投稿が存在すれば表示します。

後者は定期的にcom.atproto.repo.getRecordを叩いて削除されているものをコメントから一括削除します。

host=bsky.social
handle=yui.syui.ai
collection=app.bsky.feed.post
rkey=3kp2uq5kgns2k
cid=bafyreibytb3lnpuyus24fpib6eb3nbmjlqb2hfrztlxuygsuznpngmty3u
curl -sL "${host}/xrpc/com.atproto.repo.getRecord?repo=$handle&collection=$collection&rkey=$rkey&cid=$cid"

したがって、基本的には一度投稿されたものは表示したままにしておくか、数ヶ月に一度、cleanupするのがいいでしょう。