前言 #
本篇内容包含AI相关内容 本人是技术小白,所以这种需要编程水平的技术活就要委托一下Codex帮我写代码和使用介绍。
起因是想 Mastodon 和 Gotosocial 同步使用,之后就不用麻烦自己再用 Slurp 再导入一遍,于是想到能不能两边同步发送,看到博主 eallion 早就已经探索过这方面的内容,于是我也想着用n8n去做一个同步发文的workflow。
运行原理 #
因为 GoToSocial 兼容 Mastodon API,所以这件事本质上就是:
- Mastodon 用 Webhook 把事件推给 n8n
- n8n 收到后整理正文、媒体和回复信息
- 然后调用 GoToSocial API 发文或更新
我最后整理成了一份可直接导入的 n8n workflow JSON,主要支持这些功能:
- 新建嘟文同步到 GoToSocial
- 编辑嘟文时同步更新
- 图片和视频一起同步
- 回复保持回复关系,而不是发成独立帖
我的运行环境 #
- Mastodon
4.6.0 - GoToSocial
0.22.0 - n8n
2.26.4
使用教程 #
创建 Credentials #
首先需要创建 Gotosocial 的 Access Token,可以使用网页工具辅助,也可以参考 Gotosocial 官方文档。 随后在n8n中创建 Credentials,我使用的是 Bearer Auth account。
导入Workflow #
先把 workflow JSON 导入 n8n。
这份 workflow 使用的是 Data Table 版本,也就是会把 Mastodon 和 GoToSocial 的嘟文 ID 映射保存到一张表里,这样后续编辑和回复才能正常对应到原帖。
先创建 Data Table
在 n8n 里先建一张 Data Table,名字设成:
mastodon_gts_sync_map表里需要这些列:
mastodon_status_id
gts_status_id
last_event
mastodon_url
updated_at这张表的作用很简单:记录每条 Mastodon 嘟文在 GoToSocial 里对应的是哪条嘟文。
修改配置节点 #
导入后,打开 workflow 里的 Config - Edit Me 节点,把配置改成你自己的:
const config = {
mastodonAccountId: '',
gtsBaseUrl: 'https://your-gotosocial-domain',
gtsBearerToken: 'replace-with-your-gotosocial-bearer-token',
mappingTableName: 'mastodon_gts_sync_map',
missingUpdateBehavior: 'skip'
};其中:
-
mastodonAccountId
如果留空,就不过滤账号
如果你只想同步某一个 Mastodon 账号,可以填这个账号的 ID -
gtsBaseUrl
你的 GoToSocial 实例地址 -
gtsBearerToken
GoToSocial 的 Bearer Token -
mappingTableName
就是前面创建的 Data Table 名字 -
missingUpdateBehavior
推荐保留skip
这样遇到没有映射的编辑事件时,会直接跳过,避免误发成新帖
配置 Mastodon Webhook #
然后在 Mastodon 后台配置 Webhook,把地址指向 n8n 这个 workflow 的 Production URL。
注意这里要用生产地址,不要用测试地址。
发布 workflow 之后,Mastodon 发文、编辑、回复时,就会自动把事件推送到 n8n。
正式启用 #
全部配置完之后:
- 点击右上角
Publish - 确认 Mastodon 配置的是 workflow 的
Production URL - 不需要手动点
Execute workflow
之后只要在 Mastodon 发文,n8n 就会自动运行。
代码部分 #
{
"name": "Mastodon to GoToSocial - Data Table version",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "mastodon-to-gotosocial-datatable",
"responseMode": "onReceived",
"options": {
"responseCode": 200,
"responseData": "firstEntryJson"
}
},
"id": "f0c11111-0001-4000-8000-000000000001",
"name": "Mastodon Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-1320,
40
],
"webhookId": "mastodon-to-gotosocial-datatable"
},
{
"parameters": {
"jsCode": "const item = $input.first().json;\n\nconst config = {\n mastodonAccountId: '',\n gtsBaseUrl: 'https://你的-gotosocial-域名',\n gtsBearerToken: '填你的 GoToSocial bearer token',\n mappingTableName: 'mastodon_gts_sync_map',\n missingUpdateBehavior: 'skip'\n};\n\nreturn [{ json: { ...item, config } }];"
},
"id": "f0c11111-0002-4000-8000-000000000002",
"name": "Config - Edit Me",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1100,
40
]
},
{
"parameters": {
"jsCode": "const input = $input.first().json;\nconst config = input.config;\nconst body = input.body ?? input;\nconst payload = typeof body === 'string' ? JSON.parse(body) : body;\nlet status = payload.object ?? payload.status ?? payload;\nif (typeof status === 'string') status = JSON.parse(status);\nconst eventName = String(payload.event ?? payload.type ?? '');\n\nconst htmlToText = (html) => String(html ?? '')\n .replace(/<\\/?p[^>]*>/gi, '\\n')\n .replace(/<br\\s*\\/?\\s*>/gi, '\\n')\n .replace(/<[^>]+>/g, '')\n .replace(/ /g, ' ')\n .replace(/&/g, '&')\n .replace(/</g, '<')\n .replace(/>/g, '>')\n .replace(/"/g, '\"')\n .replace(/'/g, \"'\")\n .replace(/\\n{3,}/g, '\\n\\n')\n .trim();\n\nconst accountId = String(status.account?.id ?? '');\nconst wantedAccountId = String(config.mastodonAccountId ?? '').trim();\n\nif (!status.id) return [{ json: { skipped: true, reason: 'Webhook payload did not contain a status id', config, payload } }];\nif (wantedAccountId && accountId !== wantedAccountId) return [{ json: { skipped: true, reason: `Account id mismatch: webhook account ${accountId}, configured ${wantedAccountId}`, config, statusId: status.id } }];\nif (status.reblog) return [{ json: { skipped: true, reason: 'Reblog/boost skipped', config, statusId: status.id } }];\n\nconst normalizedEvent = eventName.includes('update') || eventName.includes('edited') || status.edited_at ? 'updated' : 'created';\n\nreturn [{\n json: {\n config,\n skipped: false,\n mastodonStatusId: String(status.id),\n parentMastodonStatusId: status.in_reply_to_id ? String(status.in_reply_to_id) : '',\n isReply: Boolean(status.in_reply_to_id),\n event: normalizedEvent,\n mastodonUrl: status.url ?? status.uri ?? '',\n sourceText: htmlToText(status.content ?? ''),\n sourceSpoilerText: status.spoiler_text ?? '',\n visibility: status.visibility ?? 'public',\n sensitive: Boolean(status.sensitive),\n language: status.language ?? 'zh',\n mediaAttachments: Array.isArray(status.media_attachments) ? status.media_attachments : []\n }\n}];"
},
"id": "f0c11111-0003-4000-8000-000000000003",
"name": "Normalize Mastodon Event",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-880,
40
]
},
{
"parameters": {
"conditions": {
"boolean": [
{
"value1": "={{ $json.skipped }}",
"value2": false
}
]
}
},
"id": "f0c11111-0004-4000-8000-000000000004",
"name": "Skip Filter",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
-660,
40
]
},
{
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"value": "={{ $json.config.mappingTableName }}",
"mode": "name"
},
"matchType": "allConditions",
"filters": {
"conditions": [
{
"keyName": "mastodon_status_id",
"keyValue": "={{ $json.mastodonStatusId }}"
}
]
},
"returnAll": true
},
"id": "f0c11111-0005-4000-8000-000000000005",
"name": "Get Existing Mapping",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
-420,
40
],
"alwaysOutputData": true
},
{
"parameters": {
"operation": "get",
"dataTableId": {
"__rl": true,
"value": "={{ $node['Config - Edit Me'].json.config.mappingTableName }}",
"mode": "name"
},
"matchType": "allConditions",
"filters": {
"conditions": [
{
"keyName": "mastodon_status_id",
"keyValue": "={{ $node['Normalize Mastodon Event'].json.parentMastodonStatusId }}"
}
]
},
"returnAll": true
},
"id": "f0c11111-0005-4000-8000-000000000005-parent",
"name": "Get Parent Mapping",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
-420,
220
],
"alwaysOutputData": true
},
{
"parameters": {
"jsCode": "const original = $node['Normalize Mastodon Event'].json;\nconst rows = $items('Get Existing Mapping');\nconst firstRow = rows[0]?.json ?? {};\nconst existingId = firstRow.gts_status_id ? String(firstRow.gts_status_id) : '';\nconst parentRows = original.isReply ? $items('Get Parent Mapping') : [];\nconst parentFirstRow = parentRows[0]?.json ?? {};\nconst parentGtsId = parentFirstRow.gts_status_id ? String(parentFirstRow.gts_status_id) : '';\nconst behavior = original.config.missingUpdateBehavior || 'skip';\n\nif (original.event === 'created' && existingId) {\n return [];\n}\n\nif (original.event === 'updated' && !existingId) {\n if (behavior === 'error') {\n throw new Error(`收到 Mastodon 编辑事件 ${original.mastodonStatusId},但 Data Table 里还没有对应的 GoToSocial 嘟文 ID。`);\n }\n if (behavior === 'skip') {\n return [];\n }\n}\n\nif (original.isReply && !parentGtsId) {\n return [];\n}\n\nconst shouldUpdate = Boolean(existingId);\nconst gtsBaseUrl = (original.config.gtsBaseUrl || '').replace(/\\/$/, '');\n\nreturn [{\n json: {\n ...original,\n operation: shouldUpdate ? 'update' : 'create',\n method: shouldUpdate ? 'PUT' : 'POST',\n url: shouldUpdate\n ? `${gtsBaseUrl}/api/v1/statuses/${existingId}`\n : `${gtsBaseUrl}/api/v1/statuses`,\n existingGtsStatusId: existingId,\n parentGtsStatusId: parentGtsId\n }\n}];"
},
"id": "f0c11111-0006-4000-8000-000000000006",
"name": "Resolve Operation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-180,
40
]
},
{
"parameters": {
"conditions": {
"number": [
{
"value1": "={{ ($json.mediaAttachments || []).length }}",
"operation": "larger",
"value2": 0
}
]
}
},
"id": "f0c11111-0007-4000-8000-000000000007",
"name": "Has Media?",
"type": "n8n-nodes-base.if",
"typeVersion": 1,
"position": [
40,
40
]
},
{
"parameters": {
"jsCode": "const original = $input.first().json;\nreturn original.mediaAttachments\n .map((media, index) => ({ media, index }))\n .filter(({ media }) => media.url || media.remote_url || media.preview_url)\n .map(({ media, index }) => ({\n json: {\n ...original,\n media: {\n index,\n id: media.id ?? '',\n type: media.type ?? '',\n url: media.url ?? media.remote_url ?? media.preview_url,\n description: media.description ?? ''\n }\n }\n }));"
},
"id": "f0c11111-0008-4000-8000-000000000008",
"name": "Prepare Media Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
280,
-120
]
},
{
"parameters": {
"url": "={{ $json.media.url }}",
"options": {
"response": {
"response": {
"responseFormat": "file",
"outputPropertyName": "data"
}
}
}
},
"id": "f0c11111-0009-4000-8000-000000000009",
"name": "Download Mastodon Media",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
500,
-120
]
},
{
"parameters": {
"method": "POST",
"url": "={{ ($json.config.gtsBaseUrl || '').replace(/\\/$/, '') + '/api/v2/media' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ 'Bearer ' + $json.config.gtsBearerToken }}"
}
]
},
"sendBody": true,
"contentType": "multipart-form-data",
"bodyParameters": {
"parameters": [
{
"parameterType": "formBinaryData",
"name": "file",
"inputDataFieldName": "data"
},
{
"name": "description",
"value": "={{ $json.media.description || '' }}"
}
]
},
"options": {}
},
"id": "f0c11111-0010-4000-8000-000000000010",
"name": "Upload Media to GoToSocial",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
720,
-120
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000
},
{
"parameters": {
"jsCode": "const uploads = $input.all();\nif (uploads.length === 0) return [];\nconst originals = $items('Prepare Media Items');\nconst original = originals[0].json;\nconst mediaIds = uploads.map(item => String(item.json.id)).filter(Boolean);\nconst body = {\n status: original.sourceText,\n spoiler_text: original.sourceSpoilerText ?? '',\n sensitive: Boolean(original.sensitive),\n visibility: original.visibility || 'public',\n language: original.language || 'zh',\n media_ids: mediaIds\n};\nif (original.parentGtsStatusId) {\n body.in_reply_to_id = original.parentGtsStatusId;\n}\n\nreturn [{\n json: {\n config: original.config,\n mastodonStatusId: original.mastodonStatusId,\n mastodonUrl: original.mastodonUrl,\n operation: original.operation,\n method: original.method,\n url: original.url,\n body\n }\n}];"
},
"id": "f0c11111-0011-4000-8000-000000000011",
"name": "Prepare Status With Media",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
940,
-120
]
},
{
"parameters": {
"method": "={{ $json.method }}",
"url": "={{ $json.url }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ 'Bearer ' + $json.config.gtsBearerToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.body) }}",
"options": {}
},
"id": "f0c11111-0012-4000-8000-000000000012",
"name": "Create or Update GoToSocial Status - Media",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1160,
-120
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000
},
{
"parameters": {
"operation": "upsert",
"dataTableId": {
"__rl": true,
"value": "={{ $node['Config - Edit Me'].json.config.mappingTableName }}",
"mode": "name"
},
"matchType": "allConditions",
"filters": {
"conditions": [
{
"keyName": "mastodon_status_id",
"keyValue": "={{ $node['Prepare Status With Media'].json.mastodonStatusId }}"
}
]
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"mastodon_status_id": "={{ $node['Prepare Status With Media'].json.mastodonStatusId }}",
"gts_status_id": "={{ $json.id }}",
"last_event": "={{ $node['Prepare Status With Media'].json.operation }}",
"mastodon_url": "={{ $node['Prepare Status With Media'].json.mastodonUrl || '' }}",
"updated_at": "={{ $now.toISO() }}"
},
"matchingColumns": [],
"schema": [
{
"id": "mastodon_status_id",
"displayName": "mastodon_status_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "gts_status_id",
"displayName": "gts_status_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "last_event",
"displayName": "last_event",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "mastodon_url",
"displayName": "mastodon_url",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "updated_at",
"displayName": "updated_at",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"id": "f0c11111-0013-4000-8000-000000000013",
"name": "Upsert Mapping - Media",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
1380,
-120
]
},
{
"parameters": {
"jsCode": "const original = $input.first().json;\nconst body = {\n status: original.sourceText ?? '',\n spoiler_text: original.sourceSpoilerText ?? '',\n sensitive: Boolean(original.sensitive),\n visibility: original.visibility || 'public',\n language: original.language || 'zh'\n};\nif (original.parentGtsStatusId) {\n body.in_reply_to_id = original.parentGtsStatusId;\n}\nreturn [{\n json: {\n config: original.config,\n mastodonStatusId: original.mastodonStatusId,\n mastodonUrl: original.mastodonUrl,\n operation: original.operation,\n method: original.method,\n url: original.url,\n body\n }\n}];"
},
"id": "f0c11111-0014-4000-8000-000000000014",
"name": "Prepare Status Without Media",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
280,
160
]
},
{
"parameters": {
"method": "={{ $json.method }}",
"url": "={{ $json.url }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "={{ 'Bearer ' + $json.config.gtsBearerToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify($json.body) }}",
"options": {}
},
"id": "f0c11111-0015-4000-8000-000000000015",
"name": "Create or Update GoToSocial Status - No Media",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
500,
160
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 3000
},
{
"parameters": {
"operation": "upsert",
"dataTableId": {
"__rl": true,
"value": "={{ $node['Config - Edit Me'].json.config.mappingTableName }}",
"mode": "name"
},
"matchType": "allConditions",
"filters": {
"conditions": [
{
"keyName": "mastodon_status_id",
"keyValue": "={{ $node['Prepare Status Without Media'].json.mastodonStatusId }}"
}
]
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"mastodon_status_id": "={{ $node['Prepare Status Without Media'].json.mastodonStatusId }}",
"gts_status_id": "={{ $json.id }}",
"last_event": "={{ $node['Prepare Status Without Media'].json.operation }}",
"mastodon_url": "={{ $node['Prepare Status Without Media'].json.mastodonUrl || '' }}",
"updated_at": "={{ $now.toISO() }}"
},
"matchingColumns": [],
"schema": [
{
"id": "mastodon_status_id",
"displayName": "mastodon_status_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "gts_status_id",
"displayName": "gts_status_id",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "last_event",
"displayName": "last_event",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "mastodon_url",
"displayName": "mastodon_url",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
},
{
"id": "updated_at",
"displayName": "updated_at",
"required": false,
"defaultMatch": false,
"display": true,
"type": "string",
"readOnly": false,
"removed": false
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": false
},
"options": {}
},
"id": "f0c11111-0016-4000-8000-000000000016",
"name": "Upsert Mapping - No Media",
"type": "n8n-nodes-base.dataTable",
"typeVersion": 1,
"position": [
720,
160
]
}
],
"connections": {
"Mastodon Webhook": {
"main": [
[
{
"node": "Config - Edit Me",
"type": "main",
"index": 0
}
]
]
},
"Config - Edit Me": {
"main": [
[
{
"node": "Normalize Mastodon Event",
"type": "main",
"index": 0
}
]
]
},
"Normalize Mastodon Event": {
"main": [
[
{
"node": "Skip Filter",
"type": "main",
"index": 0
}
]
]
},
"Skip Filter": {
"main": [
[
{
"node": "Get Existing Mapping",
"type": "main",
"index": 0
}
],
[]
]
},
"Get Existing Mapping": {
"main": [
[
{
"node": "Get Parent Mapping",
"type": "main",
"index": 0
}
]
]
},
"Get Parent Mapping": {
"main": [
[
{
"node": "Resolve Operation",
"type": "main",
"index": 0
}
]
]
},
"Resolve Operation": {
"main": [
[
{
"node": "Has Media?",
"type": "main",
"index": 0
}
]
]
},
"Has Media?": {
"main": [
[
{
"node": "Prepare Media Items",
"type": "main",
"index": 0
}
],
[
{
"node": "Prepare Status Without Media",
"type": "main",
"index": 0
}
]
]
},
"Prepare Media Items": {
"main": [
[
{
"node": "Download Mastodon Media",
"type": "main",
"index": 0
}
]
]
},
"Download Mastodon Media": {
"main": [
[
{
"node": "Upload Media to GoToSocial",
"type": "main",
"index": 0
}
]
]
},
"Upload Media to GoToSocial": {
"main": [
[
{
"node": "Prepare Status With Media",
"type": "main",
"index": 0
}
]
]
},
"Prepare Status With Media": {
"main": [
[
{
"node": "Create or Update GoToSocial Status - Media",
"type": "main",
"index": 0
}
]
]
},
"Create or Update GoToSocial Status - Media": {
"main": [
[
{
"node": "Upsert Mapping - Media",
"type": "main",
"index": 0
}
]
]
},
"Prepare Status Without Media": {
"main": [
[
{
"node": "Create or Update GoToSocial Status - No Media",
"type": "main",
"index": 0
}
]
]
},
"Create or Update GoToSocial Status - No Media": {
"main": [
[
{
"node": "Upsert Mapping - No Media",
"type": "main",
"index": 0
}
]
]
}
},
"active": false,
"pinData": {},
"settings": {
"executionOrder": "v1",
"saveDataErrorExecution": "all",
"saveDataSuccessExecution": "all",
"saveManualExecutions": true
},
"tags": [],
"versionId": "f0c11111-9999-4000-8000-000000000999"
}