长毛象 Mastodon / GoToSocial 做博客评论系统

本文尝试使用长毛象 Mastodon 兼容程序 gotosocial 搭建个人博客评论系统。

上一篇文章简单提了 长毛象做评论系统,这还是在阮一峰的网络日志 266 期周刊看到的。

mastodon 类程序不仅可以用于微博,还可以作为评论系统,如 https://cassidyjames.com/blog/fediverse-blog-comments-mastodon/ ,这位网友同步发布 toots ,然后在博文下面引用该嘟嘟的回复内容,实现了一个很不错的评论系统。这位热心网友最初缘由来源于 https://jan.wildeboer.net/2023/02/Jekyll-Mastodon-Comments/ ,对此进行了一些优化。

https://github.com/cassidyjames/cassidyjames.github.io/commit/1298d9b39b9e8adeba34f23f3ae83986e0a47260
https://github.com/cassidyjames/cassidyjames.github.io/blob/1298d9b39b9e8adeba34f23f3ae83986e0a47260/_includes/comments.html
https://github.com/cassidyjames/cassidyjames.github.io/blob/main/_includes/comments.html
https://github.com/mastodon/mastodon/issues/25892
这些是 mastodon 做热心网友的评论系统的相关信息,总的来说,需要一个机器人账户,获取机器人账户 read:statuses 权限的 accesstoken ,访问 /api/v1/statuses/:id/context,显示在前端网页评论区。

我自己按照这个思路试了试,总的来说完成度和实用性不高,没有长毛象账户的朋友不能互动,位于国内外联邦也可能出现一些问题,人数多的实例几乎都不能直连。

说到这,据说关掉最大 10 个实例,60-70% 的嘟嘟会消失。而小站点,以前我就加入过一个百来人的站点,后来过段时间打开变成域名出售了。对于国内外来说,长毛象算小众选择,毕竟有商业支持的社交媒体平台稳定而强大,可以满足大部分需求。

据信,网上提到,长毛象和推特难民息息相关,而且不止一波。逃离推特之后,对于个人用户来说,不同实例之间的规则千差万别,比如 Misskey 实例二次元就很多,掺杂着日文和绘画。如何选择也是个问题。

大多数中文实例都难以直连,这大概和最大母语互联网有关。母语使用最多的依次为中文,西班牙语,英语。人多意味着意见小概率乘以人口基数也会变成大问题,大概也就是所说的林子大了什么鸟都有。有趣的是,一些实例标称自由,然后有冗长繁琐的条实例规则,相信实际执行时还有最终解释权,这大概也算表现了理想与实际直接的矛盾性和复杂性。仔细想想怎么可能会有没有代价的自由呢?毕竟谁也不愿意打开一看就是各种违法信息,如 csam ,或引战,或口水战。当然,这些惹人讨厌的事情算是管理员的事。普遍来看,一般以实例为单位的输出己见算是常见的,至少英文实例之间确实有这样的例子。

有点扯远了,我想对于我的网站访客来说,长毛象大概比 Github 用户还少一些,不过只要能访问并开放注册,注册是很方便的。如果长毛象好友很多,或者熟悉的网站常客都在长毛象。长毛象作为评论系统这个方案就很值得一试。如果搭建一个实例对于屏幕前的你来说又轻松又简单,那么和朋友共享一个实例,使用这个方案大概也算不错的选择。

Powerby https://gitee.com/kkbt/www.ftls.xyz/blob/master/layouts/shortcodes/blog-comments-mastodon.html
hugo shortcodes 源码 , 能能登录评论,需要一个 js 支持 ,其中抽象成了的三个 class 。在 https://www.ftls.xyz/js/mastodon.js 。此外需要模板去使用。

博客页面嵌入 js https://gitee.com/kkbt/www.ftls.xyz/blob/master/static/js/mastodon.js
博客页面 shortcode https://gitee.com/kkbt/www.ftls.xyz/blob/master/layouts/shortcodes/blog-comments-mastodon.html

登录界面 https://gitee.com/kkbt/www.ftls.xyz/blob/master/static/auth.html
登录界面 CSS https://gitee.com/kkbt/www.ftls.xyz/blob/master/static/css/auth.css
登录界面由 https://github.com/takahashim/mastodon-access-token 修改而来
登录界面 CSS 由 bulma.min.css 0.9.4 简化而来

shortcodes: blog-comments-mastodon.html

 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
<div class="toot" id="toot-container">
    <!-- Toot content will be inserted here using JavaScript -->
</div>
<h2>Comments:</h2>
<div id="comments-container">
    <!-- Comments will be inserted here using JavaScript -->
</div>

<div id="comment-info"></div>
<a href="/auth.html">Auth</a>
<div>
    <textarea id="statusInput" rows="4" cols="50" placeholder="Enter your status..."></textarea>
    <div><button id="submitBtn">Submit</button></div>
</div>

<!-- moment.js 或者 相对时间 Lately.js  -->
<script type="text/javascript" src="/js/moment.js"></script>
<script type="text/javascript" src="/js/mastodon.js"></script>

<script>
    // !!!IMPORTANT!!! Bot  Mastodon access token ONLY read:statuses 
    const accessToken = "{{ .Get 1 | default .Site.Params.bot_token }}"; // 配置文件设置
    // https://example.com/@tom/statuses/xxxx
    const statusURL = "{{ .Get 0 }}"

    function commentInfo(text) {
        document.getElementById('comment-info').textContent = text;
        console.log(text);
    }
    const bot = new MastodonBot(statusURL,accessToken)
    // Login User Info
    const myAccount = new MastodonUserManager(localStorage.getItem("MASTODON_URL"), localStorage.getItem("MASTODON_ACCESS_TOKEN"), localStorage.getItem("MASTODON_ACCOUNT_URL"));
    // Fetch and display the toot and comments when the page loads
    // moment : timeFmt
    const comments = new MastodonComment(bot,myAccount,commentInfo, moment)
    comments.init();

</script>

mastodon.js

  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
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212

// UserLogin Function to Search and Post Mastodon Status
class MastodonUserManager {
    constructor(instanceUrl, accessToken, accountUrl) {
        this.instanceUrl = instanceUrl;
        this.accessToken = accessToken;
        this.accountUrl = accountUrl;
    }
    
    available() {
        return this.instanceUrl && this.accessToken && this.accountUrl
    }

    // search for a status
    async searchStatus(otherStatusURL) {
        const response = await fetch(`${this.instanceUrl}/api/v2/search?q=${encodeURIComponent(otherStatusURL)}&resolve=true&type=statuses`, {
            method: 'GET',
            headers: { 'Authorization': `Bearer ${this.accessToken}` }
        });
        if (!response.ok) {
            throw new Error(`Error searching for status: ${response.statusText}`);
        }
        const data = await response.json();
        return data;
    }

    // post a new status
    async postStatus(status) {
        const response = await fetch(`${this.instanceUrl}/api/v1/statuses`, {
            method: 'POST',
            headers: {
                'Authorization': `Bearer ${this.accessToken}`,
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(status)
        });
        if (!response.ok) {
            throw new Error(`Error posting status: ${response.statusText}`);
        }
        const data = await response.json();
        return data;
    }

    isMyStatus(status) {
        return status.account.url === this.accountUrl;
    }

    async replyStatus(status_url, reply_text, autoAt = true) {
        try {
            // Search for a status and get id
            const searchResults = await searchStatus(status_url);
            const in_reply_status = searchResults.statuses[0]
            let status = { status: reply_text, in_reply_to_id: in_reply_status.id };
            // Not myself, @it
            if ((in_reply_status.account.url != this.accountUrl) && autoAt) {
                status.status = mention ? `@${in_reply_status.account.acct} ${reply_text}` : reply_text
                status.visibility = "unlisted"
            } else if (!autoAt) {
                status.visibility = "unlisted"
            }
            // Post a new status
            const postedStatus = await postStatus(status);
            return postedStatus;
        } catch (error) {
            console.error(error.message);
            return null;
        }
    }
}


// To show the comments , Website Owner Need to Provide A Bot AccessToken with scope is 'read'
// Need url such as https://some.com/@tom/statuses/xxxx and botAccesstoken
class MastodonBot {
    constructor(mainStatusURL, botAccessToken) {
        this.mainStatusURL = mainStatusURL;
        this.botAccessToken = botAccessToken;
        this.tootContainer = document.getElementById("toot-container");
        this.commentsContainer = document.getElementById("comments-container");
    }
    // Function to fetch and display the toot and comments 
    async fetchTootAndComments(timeFmt) {
        let apiUrl = this.mainStatusURL.replace(/\/@.*?\//g, "/api/v1/statuses/");
        apiUrl = apiUrl.replace(/statuses\/statuses/g , "statuses")
        try {
            const [mainResponse, commentResponse] = await Promise.all([
                fetch(apiUrl, { headers: { "Authorization": `Bearer ${this.botAccessToken}` } }),
                fetch(apiUrl + "/context", { headers: { "Authorization": `Bearer ${this.botAccessToken}` } })
            ]);
            const [mainData, commentData] = await Promise.all([
                mainResponse.json(),
                commentResponse.json()
            ]);

            // Display the Main toot
            this.tootContainer.innerHTML = `
                    <a href="${mainData.account.url}"><strong>${mainData.account.display_name}</strong> <small>@${mainData.account.acct}</small></a>
                    <p>${mainData.content}</p>
                    <small style="display: flex; justify-content: flex-end;">${timeFmt(mainData.created_at).twitter()} | <a href="${mainData.url}">&nbsp;Reply</a></small>
                    <div><a href="${mainData.url}">Raw Toot</a></div>
                `;

            // Display comments
            this.commentsContainer.innerHTML = "";
            let sortedNyULID = commentData.descendants.sort((a, b) => a.id.localeCompare(b.id));
            sortedNyULID.forEach(comment => {
                let retoot = commentData.descendants.find(({ id }) => id === comment.in_reply_to_id)
                if (retoot) {
                    this.commentsContainer.innerHTML += `
                        <div class="comment">
                            <a href="${comment.account.url}"><strong>${comment.account.display_name}</strong> <small>@${comment.account.acct}</small></a>
                            <blockquote>> <a href="${retoot["account"].url}"><strong>${retoot["account"].display_name}</strong> <small>@${retoot["account"].acct}</small></a> : ${retoot["content"]} </blockquote>
                            ${comment.content}
                            <small style="display: flex; justify-content: flex-end;">${timeFmt(comment.created_at).twitter()} | <a href="${comment.url}">&nbsp;Reply</a></small>
                            <hr style="margin: 0.5rem 0"/>
                        </div>
                        `;
                } else {
                    this.commentsContainer.innerHTML += `
                        <div class="comment">
                            <a href="${comment.account.url}"><strong>${comment.account.display_name}</strong> <small>@${comment.account.acct}</small></a>
                            ${comment.content}
                            <small style="display: flex; justify-content: flex-end;">${timeFmt(comment.created_at).twitter()} | <a href="${comment.url}">&nbsp;Reply</a></small>
                            <hr style="margin: 0.5rem 0"/>
                        </div>
                        `;
                }
            });
        } catch (error) {
            this.commentsContainer.innerHTML += `<div>${error}</div>`
            console.error("Error fetching data:", error);
        }
    }
}

// Manager Page Comments Need Input id: statusInput
class MastodonComment {
    constructor(mastodonBot, mastodonUser, commentInfo, timeFmt) {
        this.mastodonBot = mastodonBot;
        this.mastodonUser = mastodonUser;
        this.commentInfo = commentInfo;
        this.timeFmt = timeFmt;
        this.mainStatus = null;
        this.replyStatus = null;
        this.statusInput = document.getElementById('statusInput');
        this.statusSubmitButton = document.getElementById('submitBtn');
    }

    // LoginUser Search mainStatus with LoginUser's server
    mainStatusSearch() {
        if (!this.mastodonUser.available()) { this.commentInfo("Not Login"); return; }
        this.commentInfo("Searching Main Status...");
        this.mastodonUser.searchStatus(this.mastodonBot.mainStatusURL).then(res => {
            this.commentInfo("Reply Available. Reply: Main Status");
            this.mainStatus = res.statuses[0];
            this.replyStatus = this.mainStatus;
            if (!this.mastodonUser.isMyStatus(this.replyStatus)) {
                this.commentInfo("Reply Available. Reply: Main Status: " + this.replyStatus.id + " Not my status");
                this.statusInput.value = `@${this.replyStatus.account.acct} ${this.statusInput.value}`
            }
        }).catch(error => {
            this.commentInfo("Reply Unavailable. Search Main Status with your account server error" + error.message);
        });
    }

    init() {
        const ctx = this;
        // Submit 
        this.statusSubmitButton.addEventListener('click', async () => {
            if (!this.mastodonUser.available()) { this.commentInfo("Not Login"); return; }
            let status = { status: this.statusInput.value, in_reply_to_id: this.replyStatus.id };
            if (!this.mastodonUser.isMyStatus(this.replyStatus)) {
                status.visibility = "unlisted"
            }
            if (this.statusInput.value.trim() !== '') {
                this.commentInfo("Status POST")
                this.mastodonUser.postStatus(status).then(res => {
                    ctx.commentInfo("Status POST Successfully")
                    ctx.replyStatus = ctx.mainStatus;
                    ctx.statusInput.value = ''; 
                    ctx.mastodonBot.fetchTootAndComments(this.timeFmt);
                }).catch(error => {
                    this.commentInfo("Status POST Error: " + error.message)
                })
            }
        });
        // Replay Click
        document.addEventListener('click', function (event) {
            if (event.target.tagName === 'A') {
                if (event.target.textContent === " Reply") {
                    event.preventDefault();  // 阻止默认跳转行为
                    if (!ctx.mastodonUser.available()) { ctx.commentInfo("Not Login"); return; }
                    ctx.commentInfo("Reply Searching... " + event.target.href);
                    ctx.mastodonUser.searchStatus(event.target.href).then(res => {
                        ctx.replyStatus = res.statuses[0];
                        ctx.commentInfo("Reply: " + res.statuses[0].id);
                        if (!ctx.mastodonUser.isMyStatus(ctx.replyStatus)) {
                            ctx.commentInfo("Reply: " + ctx.replyStatus.id + "\nNot my status");
                            ctx.statusInput.value = `@${ctx.replyStatus.account.acct} ${ctx.statusInput.value}`
                        }
                    }).catch(error => {
                        this.commentInfo("Reply Search " + event.target.href + "Error: " + error.message)
                    })
                }
            }
        });
        // 
        this.mastodonBot.fetchTootAndComments(this.timeFmt);
        this.mainStatusSearch();
        if (!this.mastodonUser.available()) { this.commentInfo("Not Login");}
    }
}

https://github.com/takahashim/mastodon-access-token
https://takahashim.github.io/mastodon-access-token/

登录机器人账户,使用上面的工具,选择 read 权限,获取 accesstoken 。Maston 服务器对于不使用 Token 的请求有一定限制,对于 gotosocial 来说则是必须携带。

配置文件 config/_default/params.toml 中,声明机器人 access token

1
bot_token = "MDI2ZJI2MMQTNZIYOC0ZMTG1LTK3Y2UTODY3OTVMODEYNTUX"

把上面的 shortcodes 源码,写入 layouts/shortcodes/blog-comments-mastodon.html 。

写新文章时发布前,发布条 toots ,网页点开 toots 获取 toots 的 id 。然后在博文下面引用该嘟嘟的 API URL ,代码会获取该 toots 内容和回复了该嘟嘟的嘟嘟。

1
{{< blog-comments-mastodon "https://fmb.ftls.xyz/@kkbt/statuses/01H7R08KAAJWR3PMFW69XEJD9J" >}}
  1. 目前不能自动生成嘟嘟与博客关联,或许需要一个脚本来处理这个事情,并不困难。在 git push 之前生成就行了。
  2. 还不能在网站直接回复,这个看起来或许可以简单的写一些 js 就行,网站登录后存储 accesstoken ,使用 API 发送消息。不过安全性或成问题。
  3. 游客不能留言,可以使用一个公用的账号,并使用中间层,或者使用邮箱及密码的方式快捷注册。
  4. 回复似乎不能很方便的邮箱提醒。大概可以加中间层解决,或者使用 mastodon 通知方案。

所以说计算机科学领域的任何问题都可以通过增加一个间接的中间层(抽象)来解决,还是有一点点道理的。

2024-02-20 博客 ActivityPub 互动 进行了一些优化

Comments:

Auth