feat: 实现基于 Cloudflare Worker 的 WPS 表单数据转发至 GoToSocial 功能
This commit is contained in:
67
README.md
Normal file
67
README.md
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# WPS 表单数据推送 -> GoToSocial 机器人
|
||||||
|
|
||||||
|
一个 Cloudflare Worker,用于将 WPS 表单收集的数据自动推送到 GoToSocial 发布动态。
|
||||||
|
|
||||||
|
## 工作流程
|
||||||
|
|
||||||
|
用户填写 WPS 表单 → WPS 推送数据 → Cloudflare Worker (本应用) → GoToSocial 发布动态
|
||||||
|
|
||||||
|
## 部署步骤
|
||||||
|
|
||||||
|
### 1. 安装依赖
|
||||||
|
|
||||||
|
本项目使用 [Bun](https://bun.sh/) 作为包管理器。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 登录 Cloudflare
|
||||||
|
|
||||||
|
执行以下命令,将根据提示在浏览器中登录您的 Cloudflare 账号。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun x wrangler login
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 设置密钥 (Secrets)
|
||||||
|
|
||||||
|
为了安全,GoToSocial 的地址和访问令牌需要作为密钥存储在 Cloudflare 中。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 设置你的 GoToSocial 实例 API 地址
|
||||||
|
# 示例: https://your-domain.com/api/v1/statuses
|
||||||
|
bun x wrangler secret put GOTOSOCIAL_URL
|
||||||
|
|
||||||
|
# 2. 设置你的 GoToSocial Bearer Token
|
||||||
|
# 这是用于 API 认证的访问令牌
|
||||||
|
bun x wrangler secret put GOTOSOCIAL_TOKEN
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. (可选) 本地配置
|
||||||
|
|
||||||
|
`wrangler.toml` 文件包含本地开发时的配置。
|
||||||
|
|
||||||
|
- `GOTOSOCIAL_URL`: 你的 GoToSocial 实例 API 地址。
|
||||||
|
- `GOTOSOCIAL_VISIBILITY`: 动态的可见性,默认为 `private`。可选值:`public`, `unlisted`, `private`, `direct`。
|
||||||
|
|
||||||
|
> **注意**: 在生产环境(部署后),配置的密钥 (`secrets`) 会覆盖 `wrangler.toml` 文件中的 `vars` 变量。
|
||||||
|
|
||||||
|
### 5. 部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun x wrangler deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
部署成功后,Cloudflare 会提供一个 Worker URL,格式通常为:
|
||||||
|
`https://wps-gotosocial-bot.你的用户名.workers.dev`
|
||||||
|
|
||||||
|
### 6. 配置 WPS 表单
|
||||||
|
|
||||||
|
1. 打开你的 WPS 表单,进入 **设置 -> 数据推送**。
|
||||||
|
2. 在 **URL** 输入框中,填入你部署好的 Worker URL,并在末尾加上 `/webhook`。最终的 URL 应该像这样:
|
||||||
|
```
|
||||||
|
https://wps-gotosocial-bot.你的用户名.workers.dev/webhook
|
||||||
|
```
|
||||||
|
3. 点击 **“校验并绑定”**。
|
||||||
|
4. 绑定成功后,WPS 表单的任何新提交都将自动推送到你的 GoToSocial。
|
||||||
14
package.json
Normal file
14
package.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"name": "wps-gotosocial-bot",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "WPS表单数据推送到GoToSocial的Cloudflare Worker",
|
||||||
|
"main": "src/worker.js",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"tail": "wrangler tail"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"wrangler": "^3.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
218
src/worker.js
Normal file
218
src/worker.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* WPS 表单数据推送 -> GoToSocial 机器人
|
||||||
|
*
|
||||||
|
* 严格按照WPS表单推送开发说明文档(无签名版)实现
|
||||||
|
*
|
||||||
|
* 主要逻辑:
|
||||||
|
* 1. 接收 /webhook 路径的 POST 请求。
|
||||||
|
* 2. 判断请求体中的 `event` 字段。
|
||||||
|
* 3. 如果 event 是 'bind',处理WPS的验证绑定请求。
|
||||||
|
* 4. 如果 event 是 'create_answer',格式化表单内容并推送到GoToSocial。
|
||||||
|
* 5. 提供 /health 路径用于健康检查。
|
||||||
|
*/
|
||||||
|
export default {
|
||||||
|
async fetch(request, env) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// 1. 健康检查端点
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
return jsonResponse({
|
||||||
|
status: "ok",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 只处理到 /webhook 的 POST 请求
|
||||||
|
if (url.pathname !== "/webhook") {
|
||||||
|
return new Response("Not Found", { status: 404 });
|
||||||
|
}
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
console.log("Received WPS push:", JSON.stringify(body, null, 2));
|
||||||
|
|
||||||
|
// 3. 根据 event 字段分发任务
|
||||||
|
switch (body.event) {
|
||||||
|
case "bind":
|
||||||
|
// 最终方案: WPS的bind_code需要用户在后台看到,并手动配置为secret。
|
||||||
|
// 代码从环境变量中读取这个值并返回。
|
||||||
|
const bindCodeFromEnv = env.BIND_CODE;
|
||||||
|
|
||||||
|
if (bindCodeFromEnv) {
|
||||||
|
console.log(
|
||||||
|
`Handling bind event by returning BIND_CODE from environment secret.`,
|
||||||
|
);
|
||||||
|
return jsonResponse({ bind_code: bindCodeFromEnv });
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
'BIND_CODE secret is not configured. Please set it via "wrangler secret put BIND_CODE".',
|
||||||
|
);
|
||||||
|
return jsonResponse(
|
||||||
|
{ status: 500, msg: "BIND_CODE secret is not configured." },
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
case "create_answer":
|
||||||
|
// 处理新的答卷数据
|
||||||
|
console.log("Handling create_answer event.");
|
||||||
|
return await handleDataPush(body, env);
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 对于 update_answer, delete_answer 等其他事件,暂时只记录日志,不处理
|
||||||
|
console.log(
|
||||||
|
`Received unhandled event type: "${body.event}". Ignoring.`,
|
||||||
|
);
|
||||||
|
return jsonResponse({
|
||||||
|
status: 200,
|
||||||
|
msg: `Received and ignored event: ${body.event}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Worker error:", error);
|
||||||
|
return jsonResponse({ status: 500, msg: `Error: ${error.message}` }, 500);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理数据推送 (格式化并发送)
|
||||||
|
*/
|
||||||
|
async function handleDataPush(data, env) {
|
||||||
|
// 格式化消息内容
|
||||||
|
const message = formatMessage(data);
|
||||||
|
|
||||||
|
// 发送到 GoToSocial
|
||||||
|
const result = await sendToGoToSocial(message, env);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("Successfully sent to GoToSocial.");
|
||||||
|
return jsonResponse({ status: 200, msg: "success" });
|
||||||
|
} else {
|
||||||
|
console.error("Failed to send to GoToSocial:", result.error);
|
||||||
|
// 即使发送失败,也返回200避免WPS重复推送
|
||||||
|
return jsonResponse({
|
||||||
|
status: 200,
|
||||||
|
msg: `Received but forward failed: ${result.error}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化WPS表单数据为消息文本
|
||||||
|
*/
|
||||||
|
function formatMessage(data) {
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
// 标题
|
||||||
|
lines.push(`📋 ${data.formTitle || "无标题表单"}`);
|
||||||
|
|
||||||
|
// 表单元数据
|
||||||
|
const meta = [];
|
||||||
|
if (data.aid) meta.push(`答卷ID: ${data.aid}`);
|
||||||
|
if (data.creatorName) meta.push(`提交人: ${data.creatorName}`);
|
||||||
|
if (meta.length > 0) lines.push(meta.join(" | "));
|
||||||
|
|
||||||
|
lines.push("---");
|
||||||
|
|
||||||
|
// 答案内容
|
||||||
|
if (data.answerContents && Array.isArray(data.answerContents)) {
|
||||||
|
for (const item of data.answerContents) {
|
||||||
|
// 检查 value 是否存在,不存在则不显示该行
|
||||||
|
if (
|
||||||
|
item.value !== null &&
|
||||||
|
item.value !== undefined &&
|
||||||
|
item.value !== ""
|
||||||
|
) {
|
||||||
|
const title = item.title || `(无标题问题)`;
|
||||||
|
// 对图片/文件等特殊类型进行处理
|
||||||
|
let answerText;
|
||||||
|
if (item.type === "newMultiImage" || item.type === "multiFile") {
|
||||||
|
if (Array.isArray(item.value)) {
|
||||||
|
answerText = item.value
|
||||||
|
.map(
|
||||||
|
(file) => `
|
||||||
|
- ${file.fileName || "文件"}: ${file.fileShareLink || "无链接"}`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
} else {
|
||||||
|
answerText = "附件格式错误";
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
answerText = Array.isArray(item.value)
|
||||||
|
? item.value.join(", ")
|
||||||
|
: item.value;
|
||||||
|
}
|
||||||
|
lines.push(`**${title}**: ${answerText}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交时间
|
||||||
|
const time = data.eventTs
|
||||||
|
? new Date(data.eventTs).toLocaleString("zh-CN", {
|
||||||
|
timeZone: "Asia/Shanghai",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
if (time) {
|
||||||
|
lines.push("---");
|
||||||
|
lines.push(`⏰ ${time}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 发送消息到 GoToSocial
|
||||||
|
*/
|
||||||
|
async function sendToGoToSocial(message, env) {
|
||||||
|
const url = env.GOTOSOCIAL_URL;
|
||||||
|
const token = env.GOTOSOCIAL_TOKEN;
|
||||||
|
|
||||||
|
if (!url || !token) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"GOTOSOCIAL_URL or GOTOSOCIAL_TOKEN not configured in environment variables.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: message,
|
||||||
|
visibility: env.GOTOSOCIAL_VISIBILITY || "private", // 可在环境变量中配置,默认为私密
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
return { success: true, data: result };
|
||||||
|
} else {
|
||||||
|
const errorText = await response.text();
|
||||||
|
console.error(`GoToSocial API error: ${response.status} ${errorText}`);
|
||||||
|
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Fetch to GoToSocial failed:", error);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON响应辅助函数
|
||||||
|
*/
|
||||||
|
function jsonResponse(data, status = 200) {
|
||||||
|
return new Response(JSON.stringify(data, null, 2), {
|
||||||
|
status,
|
||||||
|
headers: { "Content-Type": "application/json;charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
18
wrangler.toml
Normal file
18
wrangler.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
name = "wps-gotosocial-bot"
|
||||||
|
main = "src/worker.js"
|
||||||
|
compatibility_date = "2024-01-01"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
# GOTOSOCIAL_URL: GoToSocial 实例的 API 地址
|
||||||
|
# GOTOSOCIAL_TOKEN: GoToSocial 的 API Bearer Token
|
||||||
|
# GOTOSOCIAL_VISIBILITY: 可选, toot 的可见性, 默认 'private' (private, direct, unlisted, public)
|
||||||
|
#
|
||||||
|
# 部署前, 请务必在 Cloudflare Dashboard 中设置好 GOTOSOCIAL_URL 和 GOTOSOCIAL_TOKEN 的 secrets
|
||||||
|
# 例如: npx wrangler secret put GOTOSOCIAL_TOKEN
|
||||||
|
# GOTOSOCIAL_URL = "https://your-gotosocial-instance.com/api/v1/statuses"
|
||||||
|
GOTOSOCIAL_VISIBILITY = "private"
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
[observability.logs]
|
||||||
|
enabled = true
|
||||||
|
invocation_logs = true
|
||||||
Reference in New Issue
Block a user