feat: implement Global Topic feature

- Added isGlobal field to topics table
- Implemented global webhook endpoint /api/webhook/topic/:slug
- Updated Admin UI to support creating and managing Global Topics
- Added Global tags in topic lists for better visibility
- Refactored webhook dispatch logic for better maintainability
This commit is contained in:
2026-01-23 22:14:13 +08:00
parent ab4a93477e
commit d94292027d
6 changed files with 340 additions and 93 deletions

View File

@@ -17,58 +17,16 @@ interface Recipient {
const webhook = new Hono();
webhook.post("/:token/topic/:slug", async (c) => {
const token = c.req.param("token");
const slug = c.req.param("slug");
logger.info({ token, slug }, "[Webhook] Received request");
// 0. Find the User by Token
const user = await db.query.users.findFirst({
where: eq(users.personalToken, token),
});
if (!user) {
logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: "Invalid personal token" }, 401);
}
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
let body: Record<string, any>;
try {
const rawBody = await c.req.text();
logger.debug({ bodyLength: rawBody.length }, "[Webhook] Received raw body");
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
} catch (e) {
logger.error({ err: e }, "[Webhook] Failed to parse JSON body");
return c.json({ error: "Invalid JSON body" }, 400);
}
// 1. Find the Topic
const topic = await db.query.topics.findFirst({
where: eq(topics.slug, slug),
with: {
subscriptions: {
with: {
user: true,
},
},
groupChats: true,
},
});
if (!topic) {
logger.warn({ slug }, "[Webhook] Topic not found");
return c.json({ error: "Topic not found" }, 404);
}
logger.info({ topicName: topic.name }, "[Webhook] Found topic");
const dispatchAlert = async (
c: any,
topic: any,
body: any,
user: any | null,
) => {
// 2. Collect recipients
const userRecipients: Recipient[] = topic.subscriptions
.map((sub) => sub.user)
.map((u) => {
const userRecipients: Recipient[] = (topic.subscriptions || [])
.map((sub: any) => sub.user)
.map((u: any) => {
if (!u || !u.feishuUserId) return null;
return {
type: "user" as const,
@@ -80,11 +38,11 @@ webhook.post("/:token/topic/:slug", async (c) => {
: "user_id") as FeishuReceiveIdType,
};
})
.filter((u): u is NonNullable<typeof u> => u !== null);
.filter((u: any): u is Recipient => u !== null);
const groupRecipients: Recipient[] = topic.groupChats
.filter((g) => g.status === "approved")
.map((g) => ({
const groupRecipients: Recipient[] = (topic.groupChats || [])
.filter((g: any) => g.status === "approved")
.map((g: any) => ({
type: "group",
id: g.id, // Binding ID
name: g.name,
@@ -98,7 +56,7 @@ webhook.post("/:token/topic/:slug", async (c) => {
.insert(alertTasks)
.values({
topicSlug: topic.slug,
senderId: user.id,
senderId: user?.id || null, // Global topic might not have a sender
status: "processing",
recipientCount: allRecipients.length,
successCount: 0,
@@ -272,7 +230,7 @@ webhook.post("/:token/topic/:slug", async (c) => {
taskId: task.id,
successCount,
totalCount: allRecipients.length,
slug,
slug: topic.slug,
},
"[Webhook] Task processed",
);
@@ -284,6 +242,115 @@ webhook.post("/:token/topic/:slug", async (c) => {
status: "processing",
recipientCount: allRecipients.length,
});
};
webhook.post("/topic/:slug", async (c) => {
const slug = c.req.param("slug");
logger.info({ slug }, "[Webhook] Received global request");
// 1. Find the Topic
const topic = await db.query.topics.findFirst({
where: eq(topics.slug, slug),
with: {
subscriptions: {
with: {
user: true,
},
},
groupChats: true,
},
});
if (!topic) {
logger.warn({ slug }, "[Webhook] Topic not found");
return c.json({ error: "Topic not found" }, 404);
}
if (!topic.isGlobal) {
logger.warn({ slug }, "[Webhook] Topic is not global");
return c.json(
{ error: "This topic requires a personal token to send alerts" },
401,
);
}
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
let body: Record<string, any>;
try {
const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
} catch (_e) {
return c.json({ error: "Invalid JSON body" }, 400);
}
return dispatchAlert(c, topic, body, null);
});
webhook.post("/:token/topic/:slug", async (c) => {
const token = c.req.param("token");
const slug = c.req.param("slug");
logger.info({ token, slug }, "[Webhook] Received request");
// 1. Find the Topic
const topic = await db.query.topics.findFirst({
where: eq(topics.slug, slug),
with: {
subscriptions: {
with: {
user: true,
},
},
groupChats: true,
},
});
if (!topic) {
logger.warn({ slug }, "[Webhook] Topic not found");
return c.json({ error: "Topic not found" }, 404);
}
let user: any = null;
if (!topic.isGlobal) {
// 0. Find the User by Token
user = await db.query.users.findFirst({
where: eq(users.personalToken, token),
});
if (!user) {
logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: "Invalid personal token" }, 401);
}
}
// biome-ignore lint/suspicious/noExplicitAny: Webhook body can be any arbitrary JSON
let body: Record<string, any>;
try {
const rawBody = await c.req.text();
if (!rawBody || rawBody.trim() === "") {
return c.json({ error: "Empty body" }, 400);
}
body = JSON.parse(rawBody);
} catch (_e) {
return c.json({ error: "Invalid JSON body" }, 400);
}
return dispatchAlert(c, topic, body, user);
});
webhook.all("/topic/:slug", (c) => {
return c.json(
{
error: "Method not allowed",
message: "Please use POST to send alerts to this webhook",
format: "POST /webhook/topic/:slug",
example:
'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL',
},
405,
);
});
webhook.post("/:token/dm", async (c) => {