feat: add lint

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-14 19:36:46 +08:00
parent f414264050
commit 451793f6ce
41 changed files with 3724 additions and 3017 deletions

View File

@@ -1,174 +1,208 @@
import { Hono } from 'hono';
import { eq, and, desc, sql, gt, sum, count } from 'drizzle-orm';
import { z } from 'zod';
import { zValidator } from '@hono/zod-validator';
import { db } from './db';
import { topics, users, subscriptions, alertTasks, topicGroupChats, knownGroupChats } from './db/schema';
import { requireAuth, requireAdmin, AuthSession } from './middleware';
import { zValidator } from "@hono/zod-validator";
import { and, count, desc, eq, gt, sql, sum } from "drizzle-orm";
import { Hono } from "hono";
import { z } from "zod";
import { db } from "./db";
import {
alertTasks,
knownGroupChats,
subscriptions,
topicGroupChats,
topics,
users,
} from "./db/schema";
import { type AuthSession, requireAdmin, requireAuth } from "./middleware";
const api = new Hono<{ Variables: { session: AuthSession } }>();
const topicSchema = z.object({
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional(),
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional(),
});
const groupBindingSchema = z.object({
chatId: z.string().min(1),
name: z.string().min(1),
chatId: z.string().min(1),
name: z.string().min(1),
});
const userSchema = z.object({
name: z.string().min(1),
feishuUserId: z.string().min(1),
email: z.string().email().optional().or(z.literal('')),
name: z.string().min(1),
feishuUserId: z.string().min(1),
email: z.string().email().optional().or(z.literal("")),
});
// --- Topics ---
// --- Topics ---
api.get('/topics', requireAuth, async (c) => {
const session = c.get('session');
const isAdmin = session.isAdmin;
const currentUserId = session.id;
api.get("/topics", requireAuth, async (c) => {
const session = c.get("session");
const isAdmin = session.isAdmin;
const currentUserId = session.id;
const allTopics = await db.query.topics.findMany({
where: eq(topics.status, 'approved'),
with: {
creator: true,
approver: true,
subscriptions: {
where: (subscriptions, { eq }) =>
isAdmin ? undefined : (currentUserId ? eq(subscriptions.userId, currentUserId) : undefined),
with: {
user: true
}
}
}
});
const allTopics = await db.query.topics.findMany({
where: eq(topics.status, "approved"),
with: {
creator: true,
approver: true,
subscriptions: {
where: (subscriptions, { eq }) =>
isAdmin
? undefined
: currentUserId
? eq(subscriptions.userId, currentUserId)
: undefined,
with: {
user: true,
},
},
},
});
return c.json(allTopics);
return c.json(allTopics);
});
api.get('/topics/requests', requireAdmin, async (c) => {
const requests = await db.query.topics.findMany({
where: eq(topics.status, 'pending'),
with: {
creator: true
}
});
return c.json(requests);
api.get("/topics/requests", requireAdmin, async (c) => {
const requests = await db.query.topics.findMany({
where: eq(topics.status, "pending"),
with: {
creator: true,
},
});
return c.json(requests);
});
api.get('/topics/all', requireAdmin, async (c) => {
const allTopics = await db.query.topics.findMany({
with: {
creator: true,
approver: true,
subscriptions: true
},
orderBy: [desc(topics.createdAt)]
});
return c.json(allTopics);
api.get("/topics/all", requireAdmin, async (c) => {
const allTopics = await db.query.topics.findMany({
with: {
creator: true,
approver: true,
subscriptions: true,
},
orderBy: [desc(topics.createdAt)],
});
return c.json(allTopics);
});
api.get('/topics/my-requests', requireAuth, async (c) => {
const session = c.get('session');
const requests = await db.query.topics.findMany({
where: eq(topics.createdBy, session.id),
orderBy: [desc(topics.createdAt)],
with: {
approver: true,
}
});
return c.json(requests);
api.get("/topics/my-requests", requireAuth, async (c) => {
const session = c.get("session");
const requests = await db.query.topics.findMany({
where: eq(topics.createdBy, session.id),
orderBy: [desc(topics.createdAt)],
with: {
approver: true,
},
});
return c.json(requests);
});
api.post('/topics/:id/approve', requireAdmin, async (c) => {
const id = c.req.param('id');
const session = c.get('session');
const result = await db.update(topics)
.set({ status: 'approved', approvedBy: session.id })
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
api.post("/topics/:id/approve", requireAdmin, async (c) => {
const id = c.req.param("id");
const session = c.get("session");
const result = await db
.update(topics)
.set({ status: "approved", approvedBy: session.id })
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
});
api.post('/topics/:id/reject', requireAdmin, async (c) => {
const id = c.req.param('id');
const result = await db.update(topics)
.set({ status: 'rejected' })
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
api.post("/topics/:id/reject", requireAdmin, async (c) => {
const id = c.req.param("id");
const result = await db
.update(topics)
.set({ status: "rejected" })
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
});
// Only admins can create topics
// Authenticated users can create topics (requests)
api.post('/topics', requireAuth, zValidator('json', topicSchema), async (c) => {
const body = c.req.valid('json');
const session = c.get('session');
api.post("/topics", requireAuth, zValidator("json", topicSchema), async (c) => {
const body = c.req.valid("json");
const session = c.get("session");
const status = session.isAdmin ? 'approved' : 'pending';
const status = session.isAdmin ? "approved" : "pending";
const result = await db.insert(topics).values({
...body,
status,
createdBy: session.id,
approvedBy: session.isAdmin ? session.id : null,
}).returning();
return c.json(result[0]);
const result = await db
.insert(topics)
.values({
...body,
status,
createdBy: session.id,
approvedBy: session.isAdmin ? session.id : null,
})
.returning();
return c.json(result[0]);
});
// Only admins can update topics
api.put('/topics/:id', requireAdmin, zValidator('json', topicSchema.partial()), async (c) => {
const id = c.req.param('id');
const body = c.req.valid('json');
const result = await db.update(topics).set(body).where(eq(topics.id, id)).returning();
return c.json(result[0]);
});
api.put(
"/topics/:id",
requireAdmin,
zValidator("json", topicSchema.partial()),
async (c) => {
const id = c.req.param("id");
const body = c.req.valid("json");
const result = await db
.update(topics)
.set(body)
.where(eq(topics.id, id))
.returning();
return c.json(result[0]);
},
);
// Only admins can delete topics
api.delete('/topics/:id', requireAdmin, async (c) => {
const id = c.req.param('id');
await db.delete(topics).where(eq(topics.id, id));
return c.json({ success: true });
api.delete("/topics/:id", requireAdmin, async (c) => {
const id = c.req.param("id");
await db.delete(topics).where(eq(topics.id, id));
return c.json({ success: true });
});
// --- Users ---
api.get('/users', requireAdmin, async (c) => {
const allUsers = await db.query.users.findMany({
with: {
subscriptions: {
with: {
topic: true
}
}
}
});
return c.json(allUsers);
api.get("/users", requireAdmin, async (c) => {
const allUsers = await db.query.users.findMany({
with: {
subscriptions: {
with: {
topic: true,
},
},
},
});
return c.json(allUsers);
});
api.post('/users', requireAdmin, zValidator('json', userSchema), async (c) => {
const body = c.req.valid('json');
const result = await db.insert(users).values(body).returning();
return c.json(result[0]);
api.post("/users", requireAdmin, zValidator("json", userSchema), async (c) => {
const body = c.req.valid("json");
const result = await db.insert(users).values(body).returning();
return c.json(result[0]);
});
api.put('/users/:id', requireAdmin, zValidator('json', userSchema.partial()), async (c) => {
const id = c.req.param('id');
const body = c.req.valid('json');
const result = await db.update(users).set(body).where(eq(users.id, id)).returning();
return c.json(result[0]);
});
api.put(
"/users/:id",
requireAdmin,
zValidator("json", userSchema.partial()),
async (c) => {
const id = c.req.param("id");
const body = c.req.valid("json");
const result = await db
.update(users)
.set(body)
.where(eq(users.id, id))
.returning();
return c.json(result[0]);
},
);
api.delete('/users/:id', requireAdmin, async (c) => {
const id = c.req.param('id');
await db.delete(users).where(eq(users.id, id));
return c.json({ success: true });
api.delete("/users/:id", requireAdmin, async (c) => {
const id = c.req.param("id");
await db.delete(users).where(eq(users.id, id));
return c.json({ success: true });
});
// --- Subscriptions ---
@@ -176,159 +210,184 @@ api.delete('/users/:id', requireAdmin, async (c) => {
// --- Subscriptions ---
// Users can subscribe themselves or admins can subscribe anyone
api.post('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
const session = c.get('session');
api.post("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
const session = c.get("session");
// Check if user is subscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) {
return c.json({ error: 'You can only subscribe yourself' }, 403);
}
// Check if user is subscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) {
return c.json({ error: "You can only subscribe yourself" }, 403);
}
const result = await db.insert(subscriptions).values({ topicId, userId }).returning();
return c.json(result[0]);
const result = await db
.insert(subscriptions)
.values({ topicId, userId })
.returning();
return c.json(result[0]);
});
// Users can unsubscribe themselves or admins can unsubscribe anyone
api.delete('/topics/:topicId/subscribe/:userId', requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
const session = c.get('session');
api.delete("/topics/:topicId/subscribe/:userId", requireAuth, async (c) => {
const { topicId, userId } = c.req.param();
const session = c.get("session");
// Check if user is unsubscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) {
return c.json({ error: 'You can only unsubscribe yourself' }, 403);
}
// Check if user is unsubscribing themselves or is an admin
if (session.id !== userId && !session.isAdmin) {
return c.json({ error: "You can only unsubscribe yourself" }, 403);
}
await db.delete(subscriptions)
.where(and(
eq(subscriptions.topicId, topicId),
eq(subscriptions.userId, userId)
));
return c.json({ success: true });
await db
.delete(subscriptions)
.where(
and(eq(subscriptions.topicId, topicId), eq(subscriptions.userId, userId)),
);
return c.json({ success: true });
});
// --- Group Bindings (App Bot) ---
// Get list of known groups (for selection)
api.get('/groups', requireAuth, async (c) => {
// Return recent active groups
const groups = await db.select().from(knownGroupChats)
.orderBy(desc(knownGroupChats.lastActiveAt))
.limit(50);
return c.json(groups);
api.get("/groups", requireAuth, async (c) => {
// Return recent active groups
const groups = await db
.select()
.from(knownGroupChats)
.orderBy(desc(knownGroupChats.lastActiveAt))
.limit(50);
return c.json(groups);
});
// Get bindings for a topic
api.get('/topics/:id/groups', requireAuth, async (c) => {
const topicId = c.req.param('id');
const groups = await db.select().from(topicGroupChats)
.where(eq(topicGroupChats.topicId, topicId))
.orderBy(desc(topicGroupChats.createdAt));
return c.json(groups);
api.get("/topics/:id/groups", requireAuth, async (c) => {
const topicId = c.req.param("id");
const groups = await db
.select()
.from(topicGroupChats)
.where(eq(topicGroupChats.topicId, topicId))
.orderBy(desc(topicGroupChats.createdAt));
return c.json(groups);
});
// Bind a group to a topic
api.post('/topics/:id/groups', requireAuth, zValidator('json', groupBindingSchema), async (c) => {
const topicId = c.req.param('id');
const body = c.req.valid('json');
const session = c.get('session');
api.post(
"/topics/:id/groups",
requireAuth,
zValidator("json", groupBindingSchema),
async (c) => {
const topicId = c.req.param("id");
const body = c.req.valid("json");
const session = c.get("session");
const result = await db.insert(topicGroupChats).values({
topicId,
chatId: body.chatId,
name: body.name,
createdBy: session.id,
}).returning();
const result = await db
.insert(topicGroupChats)
.values({
topicId,
chatId: body.chatId,
name: body.name,
createdBy: session.id,
})
.returning();
return c.json(result[0]);
});
return c.json(result[0]);
},
);
// Unbind a group
api.delete('/topics/:id/groups/:bindingId', requireAuth, async (c) => {
const { id: topicId, bindingId } = c.req.param();
api.delete("/topics/:id/groups/:bindingId", requireAuth, async (c) => {
const { id: topicId, bindingId } = c.req.param();
await db.delete(topicGroupChats)
.where(and(
eq(topicGroupChats.id, bindingId),
eq(topicGroupChats.topicId, topicId)
));
await db
.delete(topicGroupChats)
.where(
and(
eq(topicGroupChats.id, bindingId),
eq(topicGroupChats.topicId, topicId),
),
);
return c.json({ success: true });
return c.json({ success: true });
});
// --- Alert Tasks ---
api.get('/alerts/tasks', requireAdmin, async (c) => {
const limit = Math.min(Number(c.req.query('limit') || 50), 100);
const tasks = await db.query.alertTasks.findMany({
orderBy: [desc(alertTasks.createdAt)],
limit,
with: {
sender: true,
logs: {
limit: 10, // Only show first 10 logs inline
}
}
});
return c.json(tasks);
api.get("/alerts/tasks", requireAdmin, async (c) => {
const limit = Math.min(Number(c.req.query("limit") || 50), 100);
const tasks = await db.query.alertTasks.findMany({
orderBy: [desc(alertTasks.createdAt)],
limit,
with: {
sender: true,
logs: {
limit: 10, // Only show first 10 logs inline
},
},
});
return c.json(tasks);
});
api.get('/alerts/tasks/:id', requireAuth, async (c) => {
const id = c.req.param('id');
const task = await db.query.alertTasks.findFirst({
where: eq(alertTasks.id, id),
with: {
sender: true,
logs: true // Show all logs for detail view
}
});
api.get("/alerts/tasks/:id", requireAuth, async (c) => {
const id = c.req.param("id");
const task = await db.query.alertTasks.findFirst({
where: eq(alertTasks.id, id),
with: {
sender: true,
logs: true, // Show all logs for detail view
},
});
if (!task) {
return c.json({ error: 'Task not found' }, 404);
}
if (!task) {
return c.json({ error: "Task not found" }, 404);
}
return c.json(task);
return c.json(task);
});
// --- Stats ---
api.get('/stats', requireAdmin, async (c) => {
// 1. Message count per topic
const topicStats = await db.select({
topicSlug: alertTasks.topicSlug,
totalTasks: count(),
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
})
.from(alertTasks)
.groupBy(alertTasks.topicSlug);
api.get("/stats", requireAdmin, async (c) => {
// 1. Message count per topic
const topicStats = await db
.select({
topicSlug: alertTasks.topicSlug,
totalTasks: count(),
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
})
.from(alertTasks)
.groupBy(alertTasks.topicSlug);
// 2. Recent metrics (last 24h)
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentStats = await db.select({
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
taskCount: count(),
})
.from(alertTasks)
.where(gt(alertTasks.createdAt, last24h));
// 2. Recent metrics (last 24h)
const last24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
const recentStats = await db
.select({
totalRecipients: sql<number>`cast(${sum(alertTasks.recipientCount)} as int)`,
totalSuccess: sql<number>`cast(${sum(alertTasks.successCount)} as int)`,
taskCount: count(),
})
.from(alertTasks)
.where(gt(alertTasks.createdAt, last24h));
const recent = recentStats[0] || { totalRecipients: 0, totalSuccess: 0, taskCount: 0 };
const totalRecipients = Number(recent.totalRecipients || 0);
const totalSuccess = Number(recent.totalSuccess || 0);
const failedCount = totalRecipients - totalSuccess;
const successRate = totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100;
const recent = recentStats[0] || {
totalRecipients: 0,
totalSuccess: 0,
taskCount: 0,
};
const totalRecipients = Number(recent.totalRecipients || 0);
const totalSuccess = Number(recent.totalSuccess || 0);
const failedCount = totalRecipients - totalSuccess;
const successRate =
totalRecipients > 0 ? (totalSuccess / totalRecipients) * 100 : 100;
return c.json({
topics: topicStats,
recent: {
alertsReceived: Number(recent.taskCount || 0),
plannedMessages: totalRecipients,
successCount: totalSuccess,
failedCount: failedCount,
successRate: successRate,
}
});
return c.json({
topics: topicStats,
recent: {
alertsReceived: Number(recent.taskCount || 0),
plannedMessages: totalRecipients,
successCount: totalSuccess,
failedCount: failedCount,
successRate: successRate,
},
});
});
export default api;

View File

@@ -1,43 +1,46 @@
import { Hono } from 'hono';
import * as lark from '@larksuiteoapi/node-sdk';
import { eventDispatcher } from '../event-handler';
import * as lark from "@larksuiteoapi/node-sdk";
import { Hono } from "hono";
import { eventDispatcher } from "../event-handler";
const feishuEvent = new Hono();
// Helper to adapt Hono request to Lark SDK request
feishuEvent.post('/', async (c) => {
try {
const headers = c.req.raw.headers;
const headerRecord: Record<string, string> = {};
headers.forEach((value, key) => {
headerRecord[key] = value;
});
feishuEvent.post("/", async (c) => {
try {
const headers = c.req.raw.headers;
const headerRecord: Record<string, string> = {};
headers.forEach((value, key) => {
headerRecord[key] = value;
});
const body = await c.req.json();
const body = await c.req.json();
// Use the official SDK functions directly for Hono compatibility
// 1. Handle URL verification (Challenge)
const { isChallenge, challenge } = lark.generateChallenge(body, {
encryptKey: process.env.FEISHU_ENCRYPT_KEY || ''
});
// Use the official SDK functions directly for Hono compatibility
// 1. Handle URL verification (Challenge)
const { isChallenge, challenge } = lark.generateChallenge(body, {
encryptKey: process.env.FEISHU_ENCRYPT_KEY || "",
});
if (isChallenge) {
return c.json(challenge);
}
if (isChallenge) {
return c.json(challenge);
}
// 2. Dispatch event
// The dispatcher expects an object containing headers and body.
// We use Object.create to put headers on the prototype so they are accessible
// but not included in JSON.stringify, which preserves signature verification.
const payload = Object.assign(Object.create({ headers: headerRecord }), body);
const result = await eventDispatcher.invoke(payload);
// 2. Dispatch event
// The dispatcher expects an object containing headers and body.
// We use Object.create to put headers on the prototype so they are accessible
// but not included in JSON.stringify, which preserves signature verification.
const payload = Object.assign(
Object.create({ headers: headerRecord }),
body,
);
const result = await eventDispatcher.invoke(payload);
return c.json(result || {});
} catch (e) {
console.error('[Feishu Event] Error:', e);
return c.json({ error: 'Internal Server Error' }, 500);
}
return c.json(result || {});
} catch (e) {
console.error("[Feishu Event] Error:", e);
return c.json({ error: "Internal Server Error" }, 500);
}
});
export default feishuEvent;

View File

@@ -1,142 +1,156 @@
import { Hono } from 'hono';
import { logger } from './lib/logger';
import { setCookie, getCookie } from 'hono/cookie';
import { db } from './db';
import { users } from './db/schema';
import { eq } from 'drizzle-orm';
import { feishuClient } from './feishu';
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { getCookie, setCookie } from "hono/cookie";
import { db } from "./db";
import { users } from "./db/schema";
import { feishuClient } from "./feishu";
import { logger } from "./lib/logger";
const auth = new Hono();
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || '').split(',').map(email => email.trim()).filter(Boolean);
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || "")
.split(",")
.map((email) => email.trim())
.filter(Boolean);
// Get the login URL for frontend to redirect
auth.get('/login-url', (c) => {
const appId = process.env.FEISHU_APP_ID;
const redirectUri = encodeURIComponent(process.env.REDIRECT_URI || 'http://localhost:5173/auth/callback');
const state = crypto.randomUUID();
auth.get("/login-url", (c) => {
const appId = process.env.FEISHU_APP_ID;
const redirectUri = encodeURIComponent(
process.env.REDIRECT_URI || "http://localhost:5173/auth/callback",
);
const state = crypto.randomUUID();
// Store state in cookie for CSRF protection
setCookie(c, 'oauth_state', state, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 600, // 10 minutes
sameSite: 'Lax',
});
// Store state in cookie for CSRF protection
setCookie(c, "oauth_state", state, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 600, // 10 minutes
sameSite: "Lax",
});
const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`;
const loginUrl = `https://open.feishu.cn/open-apis/authen/v1/index?app_id=${appId}&redirect_uri=${redirectUri}&state=${state}`;
return c.json({ loginUrl });
return c.json({ loginUrl });
});
// Handle OAuth callback
auth.get('/callback', async (c) => {
const code = c.req.query('code');
const state = c.req.query('state');
const storedState = getCookie(c, 'oauth_state');
auth.get("/callback", async (c) => {
const code = c.req.query("code");
const state = c.req.query("state");
const storedState = getCookie(c, "oauth_state");
// Verify state for CSRF protection
if (!state || state !== storedState) {
return c.json({ error: 'Invalid state parameter' }, 400);
}
// Verify state for CSRF protection
if (!state || state !== storedState) {
return c.json({ error: "Invalid state parameter" }, 400);
}
if (!code) {
return c.json({ error: 'No code provided' }, 400);
}
if (!code) {
return c.json({ error: "No code provided" }, 400);
}
try {
// Exchange code for user access token and user info
const userData = await feishuClient.getUserAccessToken(code);
try {
// Exchange code for user access token and user info
const userData = await feishuClient.getUserAccessToken(code);
// Check if user exists, otherwise create
let user = await db.query.users.findFirst({
where: eq(users.feishuUserId, userData.open_id),
});
// Check if user exists, otherwise create
let user = await db.query.users.findFirst({
where: eq(users.feishuUserId, userData.open_id),
});
const isAdmin = ADMIN_EMAILS.includes(userData.email || '');
const isAdmin = ADMIN_EMAILS.includes(userData.email || "");
if (!user) {
// Create new user
const result = await db.insert(users).values({
name: userData.name,
feishuUserId: userData.open_id,
email: userData.email || null,
isAdmin,
}).returning();
user = result[0];
} else {
// Update user info (in case name or admin status changed)
const result = await db.update(users)
.set({
name: userData.name,
email: userData.email || user.email,
isAdmin,
})
.where(eq(users.id, user.id))
.returning();
user = result[0];
}
if (!user) {
// Create new user
const result = await db
.insert(users)
.values({
name: userData.name,
feishuUserId: userData.open_id,
email: userData.email || null,
isAdmin,
})
.returning();
user = result[0];
} else {
// Update user info (in case name or admin status changed)
const result = await db
.update(users)
.set({
name: userData.name,
email: userData.email || user.email,
isAdmin,
})
.where(eq(users.id, user.id))
.returning();
user = result[0];
}
// Set session cookie
setCookie(c, 'session', JSON.stringify({
id: user.id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
personalToken: user.personalToken,
}), {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: 'Lax',
});
// Set session cookie
setCookie(
c,
"session",
JSON.stringify({
id: user.id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
personalToken: user.personalToken,
}),
{
httpOnly: true,
secure: process.env.NODE_ENV === "production",
maxAge: 60 * 60 * 24 * 7, // 7 days
sameSite: "Lax",
},
);
return c.json({
success: true,
user: {
id: user.id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
},
});
} catch (error) {
logger.error({ err: error }, 'OAuth callback error');
return c.json({ error: 'Authentication failed' }, 500);
}
return c.json({
success: true,
user: {
id: user.id,
name: user.name,
email: user.email,
isAdmin: user.isAdmin,
},
});
} catch (error) {
logger.error({ err: error }, "OAuth callback error");
return c.json({ error: "Authentication failed" }, 500);
}
});
// Get current user from session
auth.get('/me', (c) => {
const sessionCookie = getCookie(c, 'session');
auth.get("/me", (c) => {
const sessionCookie = getCookie(c, "session");
if (!sessionCookie) {
return c.json({ error: 'Not authenticated' }, 401);
}
if (!sessionCookie) {
return c.json({ error: "Not authenticated" }, 401);
}
try {
const session = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session) {
return c.json({ error: 'Not authenticated' }, 401);
}
// Normalize user object to ensure id is present (handle legacy session with userId)
const user = {
...session,
id: session.id || session.userId,
};
return c.json({ user });
} catch (error) {
logger.error({ err: error }, '[Auth] Failed to parse session cookie');
return c.json({ error: 'Invalid session' }, 401);
}
try {
const session = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session) {
return c.json({ error: "Not authenticated" }, 401);
}
// Normalize user object to ensure id is present (handle legacy session with userId)
const user = {
...session,
id: session.id || session.userId,
};
return c.json({ user });
} catch (error) {
logger.error({ err: error }, "[Auth] Failed to parse session cookie");
return c.json({ error: "Invalid session" }, 401);
}
});
// Logout
auth.post('/logout', (c) => {
setCookie(c, 'session', '', {
maxAge: 0,
});
return c.json({ success: true });
auth.post("/logout", (c) => {
setCookie(c, "session", "", {
maxAge: 0,
});
return c.json({ success: true });
});
export default auth;

View File

@@ -1,7 +1,9 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";
const connectionString = process.env.DATABASE_URL || 'postgres://postgres:password@localhost:5432/alert_message_center';
const connectionString =
process.env.DATABASE_URL ||
"postgres://postgres:password@localhost:5432/alert_message_center";
const client = postgres(connectionString);
export const db = drizzle(client, { schema });

View File

@@ -1,131 +1,173 @@
import { pgTable, text, integer, primaryKey, boolean, jsonb, timestamp } from 'drizzle-orm/pg-core';
import { relations } from 'drizzle-orm';
import { relations } from "drizzle-orm";
import {
boolean,
integer,
jsonb,
pgTable,
primaryKey,
text,
timestamp,
} from "drizzle-orm/pg-core";
// Topics: 类似于 Kafka 的 Topic 或 告警的 Tag例如 "payment-service",
export const topics = pgTable('topics', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
slug: text('slug').notNull().unique(), // 告警发送时使用的 key
name: text('name').notNull(),
description: text('description'),
status: text('status', { enum: ['pending', 'approved', 'rejected'] }).default('approved').notNull(),
createdBy: text('created_by').references(() => users.id),
approvedBy: text('approved_by').references(() => users.id),
createdAt: timestamp('created_at').defaultNow().notNull(),
export const topics = pgTable("topics", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
slug: text("slug").notNull().unique(), // 告警发送时使用的 key
name: text("name").notNull(),
description: text("description"),
status: text("status", { enum: ["pending", "approved", "rejected"] })
.default("approved")
.notNull(),
createdBy: text("created_by").references(() => users.id),
approvedBy: text("approved_by").references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
// Group Chats: App Bot 所在的群绑定
export const topicGroupChats = pgTable('topic_group_chats', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
chatId: text('chat_id').notNull(), // 飞书群 chat_id
name: text('name').notNull(), // 群名称快照
createdAt: timestamp('created_at').defaultNow().notNull(),
createdBy: text('created_by').references(() => users.id),
export const topicGroupChats = pgTable("topic_group_chats", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
topicId: text("topic_id")
.notNull()
.references(() => topics.id, { onDelete: "cascade" }),
chatId: text("chat_id").notNull(), // 飞书群 chat_id
name: text("name").notNull(), // 群名称快照
createdAt: timestamp("created_at").defaultNow().notNull(),
createdBy: text("created_by").references(() => users.id),
});
export const topicGroupChatsRelations = relations(topicGroupChats, ({ one }) => ({
topic: one(topics, {
fields: [topicGroupChats.topicId],
references: [topics.id],
}),
creator: one(users, {
fields: [topicGroupChats.createdBy],
references: [users.id],
}),
}));
export const topicGroupChatsRelations = relations(
topicGroupChats,
({ one }) => ({
topic: one(topics, {
fields: [topicGroupChats.topicId],
references: [topics.id],
}),
creator: one(users, {
fields: [topicGroupChats.createdBy],
references: [users.id],
}),
}),
);
// Known Group Chats: 机器人已知的群 (通过事件发现)
export const knownGroupChats = pgTable('known_group_chats', {
chatId: text('chat_id').primaryKey(), // 飞书 chat_id
name: text('name').notNull(),
lastActiveAt: timestamp('last_active_at').defaultNow(),
export const knownGroupChats = pgTable("known_group_chats", {
chatId: text("chat_id").primaryKey(), // 飞书 chat_id
name: text("name").notNull(),
lastActiveAt: timestamp("last_active_at").defaultNow(),
});
export const topicsRelations = relations(topics, ({ many, one }) => ({
subscriptions: many(subscriptions),
groupChats: many(topicGroupChats),
creator: one(users, {
fields: [topics.createdBy],
references: [users.id],
relationName: 'creator',
}),
approver: one(users, {
fields: [topics.approvedBy],
references: [users.id],
relationName: 'approver',
}),
subscriptions: many(subscriptions),
groupChats: many(topicGroupChats),
creator: one(users, {
fields: [topics.createdBy],
references: [users.id],
relationName: "creator",
}),
approver: one(users, {
fields: [topics.approvedBy],
references: [users.id],
relationName: "approver",
}),
}));
export const users = pgTable('users', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
name: text('name').notNull(),
feishuUserId: text('feishu_user_id').notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
email: text('email').unique(),
isAdmin: boolean('is_admin').default(false),
personalToken: text('personal_token').notNull().unique().$defaultFn(() => crypto.randomUUID().replace(/-/g, '')),
export const users = pgTable("users", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
name: text("name").notNull(),
feishuUserId: text("feishu_user_id").notNull(), // 必须有飞书 ID 才能私聊 (open_id 或 user_id)
email: text("email").unique(),
isAdmin: boolean("is_admin").default(false),
personalToken: text("personal_token")
.notNull()
.unique()
.$defaultFn(() => crypto.randomUUID().replace(/-/g, "")),
});
export const usersRelations = relations(users, ({ many }) => ({
subscriptions: many(subscriptions),
createdTopics: many(topics, { relationName: 'creator' }),
approvedTopics: many(topics, { relationName: 'approver' }),
subscriptions: many(subscriptions),
createdTopics: many(topics, { relationName: "creator" }),
approvedTopics: many(topics, { relationName: "approver" }),
}));
// Subscriptions: 用户直接订阅 Topic
export const subscriptions = pgTable('subscriptions', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
topicId: text('topic_id').notNull().references(() => topics.id, { onDelete: 'cascade' }),
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (t) => ({
pk: primaryKey({ columns: [t.userId, t.topicId] }),
}));
export const subscriptions = pgTable(
"subscriptions",
{
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
topicId: text("topic_id")
.notNull()
.references(() => topics.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").defaultNow().notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.topicId] }),
}),
);
export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({
user: one(users, {
fields: [subscriptions.userId],
references: [users.id],
}),
topic: one(topics, {
fields: [subscriptions.topicId],
references: [topics.id],
}),
user: one(users, {
fields: [subscriptions.userId],
references: [users.id],
}),
topic: one(topics, {
fields: [subscriptions.topicId],
references: [topics.id],
}),
}));
// API Tasks: 记录 webhook 请求的处理状态
export const alertTasks = pgTable('alert_tasks', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
topicSlug: text('topic_slug'),
senderId: text('sender_id').references(() => users.id), // 记录是谁发送的 (通过 personal_token)
status: text('status', { enum: ['pending', 'processing', 'completed', 'failed'] }).default('pending').notNull(),
recipientCount: integer('recipient_count').default(0),
successCount: integer('success_count').default(0),
payload: jsonb('payload'), // 存储 webhook body
error: text('error'),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull(),
export const alertTasks = pgTable("alert_tasks", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
topicSlug: text("topic_slug"),
senderId: text("sender_id").references(() => users.id), // 记录是谁发送的 (通过 personal_token)
status: text("status", {
enum: ["pending", "processing", "completed", "failed"],
})
.default("pending")
.notNull(),
recipientCount: integer("recipient_count").default(0),
successCount: integer("success_count").default(0),
payload: jsonb("payload"), // 存储 webhook body
error: text("error"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
// Logs for each recipient in a task (optional detail)
export const alertLogs = pgTable('alert_logs', {
id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()),
taskId: text('task_id').notNull().references(() => alertTasks.id, { onDelete: 'cascade' }),
userId: text('user_id'), // Optional, in case user is deleted later
status: text('status', { enum: ['sent', 'failed'] }).notNull(),
error: text('error'),
createdAt: timestamp('created_at').defaultNow().notNull(),
export const alertLogs = pgTable("alert_logs", {
id: text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
taskId: text("task_id")
.notNull()
.references(() => alertTasks.id, { onDelete: "cascade" }),
userId: text("user_id"), // Optional, in case user is deleted later
status: text("status", { enum: ["sent", "failed"] }).notNull(),
error: text("error"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});
export const alertTasksRelations = relations(alertTasks, ({ many, one }) => ({
logs: many(alertLogs),
sender: one(users, {
fields: [alertTasks.senderId],
references: [users.id],
}),
logs: many(alertLogs),
sender: one(users, {
fields: [alertTasks.senderId],
references: [users.id],
}),
}));
export const alertLogsRelations = relations(alertLogs, ({ one }) => ({
task: one(alertTasks, {
fields: [alertLogs.taskId],
references: [alertTasks.id],
}),
task: one(alertTasks, {
fields: [alertLogs.taskId],
references: [alertTasks.id],
}),
}));

View File

@@ -1,38 +1,45 @@
import { db } from './db';
import { knownGroupChats, topicGroupChats } from './db/schema';
import { eq } from 'drizzle-orm';
import * as lark from '@larksuiteoapi/node-sdk';
import { logger } from './lib/logger';
import * as lark from "@larksuiteoapi/node-sdk";
import { eq } from "drizzle-orm";
import { db } from "./db";
import { knownGroupChats, topicGroupChats } from "./db/schema";
import { logger } from "./lib/logger";
export const eventDispatcher = new lark.EventDispatcher({
encryptKey: process.env.FEISHU_ENCRYPT_KEY,
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
encryptKey: process.env.FEISHU_ENCRYPT_KEY,
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN,
}).register({
'im.chat.member.bot.added_v1': async (data) => {
const { chat_id, name } = data as any;
logger.info({ chat_id, name }, '[Feishu Event] Bot added to group');
"im.chat.member.bot.added_v1": async (data) => {
const { chat_id, name } = data as any;
logger.info({ chat_id, name }, "[Feishu Event] Bot added to group");
if (chat_id) {
await db.insert(knownGroupChats).values({
chatId: chat_id,
name: name || 'Unknown Group',
lastActiveAt: new Date(),
}).onConflictDoUpdate({
target: knownGroupChats.chatId,
set: {
name: name || 'Unknown Group',
lastActiveAt: new Date(),
}
});
}
},
'im.chat.member.bot.deleted_v1': async (data) => {
const { chat_id } = data as any;
logger.info({ chat_id }, '[Feishu Event] Bot removed from group');
if (chat_id) {
await db
.insert(knownGroupChats)
.values({
chatId: chat_id,
name: name || "Unknown Group",
lastActiveAt: new Date(),
})
.onConflictDoUpdate({
target: knownGroupChats.chatId,
set: {
name: name || "Unknown Group",
lastActiveAt: new Date(),
},
});
}
},
"im.chat.member.bot.deleted_v1": async (data) => {
const { chat_id } = data as any;
logger.info({ chat_id }, "[Feishu Event] Bot removed from group");
if (chat_id) {
await db.delete(knownGroupChats).where(eq(knownGroupChats.chatId, chat_id));
await db.delete(topicGroupChats).where(eq(topicGroupChats.chatId, chat_id));
}
},
if (chat_id) {
await db
.delete(knownGroupChats)
.where(eq(knownGroupChats.chatId, chat_id));
await db
.delete(topicGroupChats)
.where(eq(topicGroupChats.chatId, chat_id));
}
},
});

View File

@@ -1,72 +1,78 @@
import * as lark from '@larksuiteoapi/node-sdk';
import { logger } from './lib/logger';
import * as lark from "@larksuiteoapi/node-sdk";
import { logger } from "./lib/logger";
export class FeishuClient {
public client: lark.Client;
public appId: string;
public appSecret: string;
public client: lark.Client;
public appId: string;
public appSecret: string;
constructor(appId: string, appSecret: string) {
this.appId = appId;
this.appSecret = appSecret;
this.client = new lark.Client({
appId: appId,
appSecret: appSecret,
disableTokenCache: false,
});
}
constructor(appId: string, appSecret: string) {
this.appId = appId;
this.appSecret = appSecret;
this.client = new lark.Client({
appId: appId,
appSecret: appSecret,
disableTokenCache: false,
});
}
async sendMessage(receiveId: string, receiveIdType: 'open_id' | 'user_id' | 'email' | 'chat_id', msgType: string, content: any) {
// Content needs to be stringified for 'text' type in API, but SDK might handle it differently?
// Actually SDK expects 'content' as string JSON for 'im.v1.messages.create'
const contentStr = typeof content === 'string' ? content : JSON.stringify(content);
async sendMessage(
receiveId: string,
receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
msgType: string,
content: any,
) {
// Content needs to be stringified for 'text' type in API, but SDK might handle it differently?
// Actually SDK expects 'content' as string JSON for 'im.v1.messages.create'
const contentStr =
typeof content === "string" ? content : JSON.stringify(content);
try {
const response = await this.client.im.message.create({
params: {
receive_id_type: receiveIdType,
},
data: {
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
},
});
try {
const response = await this.client.im.message.create({
params: {
receive_id_type: receiveIdType,
},
data: {
receive_id: receiveId,
msg_type: msgType,
content: contentStr,
},
});
if (response.code !== 0) {
logger.error({ response }, 'Feishu send message error');
throw new Error(`Failed to send message: ${response.msg}`);
}
return response.data;
} catch (e) {
console.error('Feishu SDK error:', e);
throw e;
}
}
if (response.code !== 0) {
logger.error({ response }, "Feishu send message error");
throw new Error(`Failed to send message: ${response.msg}`);
}
return response.data;
} catch (e) {
console.error("Feishu SDK error:", e);
throw e;
}
}
async getUserAccessToken(code: string): Promise<any> {
try {
const response = await this.client.authen.accessToken.create({
data: {
grant_type: 'authorization_code',
code,
},
});
async getUserAccessToken(code: string): Promise<any> {
try {
const response = await this.client.authen.accessToken.create({
data: {
grant_type: "authorization_code",
code,
},
});
if (response.code !== 0) {
logger.error({ response }, 'Feishu get user access token error');
throw new Error(`Failed to get user access token: ${response.msg}`);
}
return response.data;
} catch (e) {
console.error('Feishu SDK error:', e);
throw e;
}
}
if (response.code !== 0) {
logger.error({ response }, "Feishu get user access token error");
throw new Error(`Failed to get user access token: ${response.msg}`);
}
return response.data;
} catch (e) {
console.error("Feishu SDK error:", e);
throw e;
}
}
}
// Singleton instance
export const feishuClient = new FeishuClient(
process.env.FEISHU_APP_ID || '',
process.env.FEISHU_APP_SECRET || ''
process.env.FEISHU_APP_ID || "",
process.env.FEISHU_APP_SECRET || "",
);

View File

@@ -1,47 +1,52 @@
import { Hono } from 'hono';
import { logger } from './lib/logger';
import { cors } from 'hono/cors';
import { serveStatic } from 'hono/bun';
import { db } from './db';
import { topics } from './db/schema';
import webhook from './webhook';
import api from './api';
import auth from './auth';
import { Hono } from "hono";
import { serveStatic } from "hono/bun";
import { cors } from "hono/cors";
import api from "./api";
import auth from "./auth";
import { db } from "./db";
import { topics } from "./db/schema";
import { logger } from "./lib/logger";
import webhook from "./webhook";
const app = new Hono();
// Enable CORS for frontend
app.use('/*', cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
credentials: true,
}));
app.use(
"/*",
cors({
origin: process.env.FRONTEND_URL || "http://localhost:5173",
credentials: true,
}),
);
import feishuEvent from './api/feishu-event';
import feishuEvent from "./api/feishu-event";
// ...
// API Routes
const routes = app.route('/api/auth', auth)
.route('/api', api)
.route('/api/feishu/event', feishuEvent)
.route('/webhook', webhook);
const routes = app
.route("/api/auth", auth)
.route("/api", api)
.route("/api/feishu/event", feishuEvent)
.route("/webhook", webhook);
// Serve static files (Frontend)
app.use('/*', serveStatic({ root: './public' }));
app.get('*', serveStatic({ path: './public/index.html' }));
app.use("/*", serveStatic({ root: "./public" }));
app.get("*", serveStatic({ path: "./public/index.html" }));
app.onError((err, c) => {
logger.error({ err, method: c.req.method, url: c.req.url }, 'Global Error');
return c.json({ error: err.message || 'Internal Server Error' }, 500);
logger.error({ err, method: c.req.method, url: c.req.url }, "Global Error");
return c.json({ error: err.message || "Internal Server Error" }, 500);
});
app.get('/topics', async (c) => {
const allTopics = await db.select().from(topics);
return c.json(allTopics);
app.get("/topics", async (c) => {
const allTopics = await db.select().from(topics);
return c.json(allTopics);
});
// Start WebSocket if enabled
import { startWebSocket } from './ws';
import { startWebSocket } from "./ws";
startWebSocket();
export type AppType = typeof routes;

View File

@@ -1,19 +1,19 @@
import pino from 'pino';
import pino from "pino";
const isDevelopment = process.env.NODE_ENV !== 'production';
const isDevelopment = process.env.NODE_ENV !== "production";
export const logger = pino({
level: process.env.LOG_LEVEL || 'info',
transport: isDevelopment
? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname',
},
}
: undefined,
level: process.env.LOG_LEVEL || "info",
transport: isDevelopment
? {
target: "pino-pretty",
options: {
colorize: true,
translateTime: "HH:MM:ss Z",
ignore: "pid,hostname",
},
}
: undefined,
});
export default logger;

View File

@@ -1,51 +1,58 @@
import { Context, Next } from 'hono';
import { getCookie } from 'hono/cookie';
import type { Context, Next } from "hono";
import { getCookie } from "hono/cookie";
export interface AuthSession {
id: string;
name: string;
email: string | null;
isAdmin: boolean;
id: string;
name: string;
email: string | null;
isAdmin: boolean;
}
export async function requireAuth(c: Context, next: Next) {
const sessionCookie = getCookie(c, 'session');
const sessionCookie = getCookie(c, "session");
if (!sessionCookie) {
return c.json({ error: 'Authentication required' }, 401);
}
if (!sessionCookie) {
return c.json({ error: "Authentication required" }, 401);
}
try {
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
if (!session) {
return c.json({ error: 'Authentication required' }, 401);
}
c.set('session', session);
await next();
} catch (error) {
console.error('[Middleware] Failed to parse session cookie:', error);
return c.json({ error: 'Invalid session' }, 401);
}
try {
const session: AuthSession = sessionCookie
? JSON.parse(sessionCookie)
: null;
if (!session) {
return c.json({ error: "Authentication required" }, 401);
}
c.set("session", session);
await next();
} catch (error) {
console.error("[Middleware] Failed to parse session cookie:", error);
return c.json({ error: "Invalid session" }, 401);
}
}
export async function requireAdmin(c: Context, next: Next) {
const sessionCookie = getCookie(c, 'session');
const sessionCookie = getCookie(c, "session");
if (!sessionCookie) {
return c.json({ error: 'Authentication required' }, 401);
}
if (!sessionCookie) {
return c.json({ error: "Authentication required" }, 401);
}
try {
const session: AuthSession = sessionCookie ? JSON.parse(sessionCookie) : null;
try {
const session: AuthSession = sessionCookie
? JSON.parse(sessionCookie)
: null;
if (!session || !session.isAdmin) {
return c.json({ error: 'Admin access required' }, 403);
}
if (!session || !session.isAdmin) {
return c.json({ error: "Admin access required" }, 403);
}
c.set('session', session);
await next();
} catch (error) {
console.error('[Middleware] Failed to parse session cookie in requireAdmin:', error);
return c.json({ error: 'Invalid session' }, 401);
}
c.set("session", session);
await next();
} catch (error) {
console.error(
"[Middleware] Failed to parse session cookie in requireAdmin:",
error,
);
return c.json({ error: "Invalid session" }, 401);
}
}

View File

@@ -1,148 +1,187 @@
import app from './index';
import { db } from './db';
import { users, topics, subscriptions } from './db/schema';
import { eq } from 'drizzle-orm';
import { eq } from "drizzle-orm";
import { db } from "./db";
import { subscriptions, topics, users } from "./db/schema";
import app from "./index";
async function verify() {
console.log('Starting Verification...');
let errors = 0;
console.log("Starting Verification...");
let errors = 0;
// 1. Setup Test Data
const timestamp = Date.now();
// 1. Setup Test Data
const timestamp = Date.now();
// Create Non-Admin User
const [userUser] = await db.insert(users).values({
name: `TestUser_${timestamp}`,
feishuUserId: `test_user_${timestamp}`,
email: `test_user_${timestamp}@example.com`,
isAdmin: false
}).returning();
// Create Non-Admin User
const [userUser] = await db
.insert(users)
.values({
name: `TestUser_${timestamp}`,
feishuUserId: `test_user_${timestamp}`,
email: `test_user_${timestamp}@example.com`,
isAdmin: false,
})
.returning();
// Create Admin User
const [adminUser] = await db.insert(users).values({
name: `TestAdmin_${timestamp}`,
feishuUserId: `test_admin_${timestamp}`,
email: `test_admin_${timestamp}@example.com`,
isAdmin: true
}).returning();
// Create Admin User
const [adminUser] = await db
.insert(users)
.values({
name: `TestAdmin_${timestamp}`,
feishuUserId: `test_admin_${timestamp}`,
email: `test_admin_${timestamp}@example.com`,
isAdmin: true,
})
.returning();
// Create Topic
const [topic] = await db.insert(topics).values({
name: `TestTopic_${timestamp}`,
slug: `test-topic-${timestamp}`,
description: 'Test Description'
}).returning();
// Create Topic
const [topic] = await db
.insert(topics)
.values({
name: `TestTopic_${timestamp}`,
slug: `test-topic-${timestamp}`,
description: "Test Description",
})
.returning();
// Subscribe User to Topic
await db.insert(subscriptions).values({
userId: userUser.id,
topicId: topic.id
});
// Subscribe User to Topic
await db.insert(subscriptions).values({
userId: userUser.id,
topicId: topic.id,
});
console.log('Test Data Created:', { user: userUser.id, admin: adminUser.id, topic: topic.id });
console.log("Test Data Created:", {
user: userUser.id,
admin: adminUser.id,
topic: topic.id,
});
try {
// 2. Test GET /users (Admin Only)
try {
// 2. Test GET /users (Admin Only)
// Test as Non-Admin
const sessionUser = { userId: userUser.id, name: userUser.name, email: userUser.email, isAdmin: userUser.isAdmin };
const req1 = new Request('http://localhost/api/users', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}`
}
});
const res1 = await app.request(req1);
// Test as Non-Admin
const sessionUser = {
userId: userUser.id,
name: userUser.name,
email: userUser.email,
isAdmin: userUser.isAdmin,
};
const req1 = new Request("http://localhost/api/users", {
headers: {
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionUser))}`,
},
});
const res1 = await app.request(req1);
if (res1.status === 403) {
console.log('✅ PASS: GET /users as Non-Admin returned 403');
} else {
console.error(`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`);
errors++;
}
if (res1.status === 403) {
console.log("✅ PASS: GET /users as Non-Admin returned 403");
} else {
console.error(
`❌ FAIL: GET /users as Non-Admin returned ${res1.status} (expected 403)`,
);
errors++;
}
// Test as Admin
const sessionAdmin = { userId: adminUser.id, name: adminUser.name, email: adminUser.email, isAdmin: adminUser.isAdmin };
const req2 = new Request('http://localhost/api/users', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`
}
});
const res2 = await app.request(req2);
// Test as Admin
const sessionAdmin = {
userId: adminUser.id,
name: adminUser.name,
email: adminUser.email,
isAdmin: adminUser.isAdmin,
};
const req2 = new Request("http://localhost/api/users", {
headers: {
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`,
},
});
const res2 = await app.request(req2);
if (res2.status === 200) {
console.log('✅ PASS: GET /users as Admin returned 200');
} else {
console.error(`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`);
errors++;
}
if (res2.status === 200) {
console.log("✅ PASS: GET /users as Admin returned 200");
} else {
console.error(
`❌ FAIL: GET /users as Admin returned ${res2.status} (expected 200)`,
);
errors++;
}
// 3. Test GET /topics (Filtered)
// 3. Test GET /topics (Filtered)
// Test as Non-Admin (Should see ONLY their subscription)
const req3 = new Request('http://localhost/api/topics', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionUser))}`
}
});
const res3 = await app.request(req3);
const data3 = await res3.json();
// Test as Non-Admin (Should see ONLY their subscription)
const req3 = new Request("http://localhost/api/topics", {
headers: {
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionUser))}`,
},
});
const res3 = await app.request(req3);
const data3 = await res3.json();
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
if (targetTopic) {
if (targetTopic.subscriptions.length === 1 && targetTopic.subscriptions[0].userId === userUser.id) {
console.log('✅ PASS: GET /topics as Non-Admin shows correct personal subscription');
} else {
console.error('❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:', targetTopic.subscriptions);
errors++;
}
} else {
console.error('❌ FAIL: Test topic not found in list');
errors++;
}
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
if (targetTopic) {
if (
targetTopic.subscriptions.length === 1 &&
targetTopic.subscriptions[0].userId === userUser.id
) {
console.log(
"✅ PASS: GET /topics as Non-Admin shows correct personal subscription",
);
} else {
console.error(
"❌ FAIL: GET /topics as Non-Admin showed wrong subscriptions:",
targetTopic.subscriptions,
);
errors++;
}
} else {
console.error("❌ FAIL: Test topic not found in list");
errors++;
}
// Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too)
// Actually, let's just check that Admin sees the User's subscription.
// In my logic: isAdmin ? undefined (all) : ...
// So Admin should see User's subscription.
// Test as Admin (Should see ALL subscriptions?? Wait, I didn't add another subscription. Let's add admin subscription too)
// Actually, let's just check that Admin sees the User's subscription.
// In my logic: isAdmin ? undefined (all) : ...
// So Admin should see User's subscription.
const req4 = new Request('http://localhost/api/topics', {
headers: {
'Cookie': `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`
}
});
const res4 = await app.request(req4);
const data4 = await res4.json();
const req4 = new Request("http://localhost/api/topics", {
headers: {
Cookie: `session=${encodeURIComponent(JSON.stringify(sessionAdmin))}`,
},
});
const res4 = await app.request(req4);
const data4 = await res4.json();
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
// Should see the subscription for userUser
const hasUserSub = targetTopicAdmin.subscriptions.some((s: any) => s.userId === userUser.id);
if (hasUserSub) {
console.log('✅ PASS: GET /topics as Admin sees other users subscriptions');
} else {
console.error('❌ FAIL: GET /topics as Admin did NOT see other users subscriptions');
errors++;
}
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
// Should see the subscription for userUser
const hasUserSub = targetTopicAdmin.subscriptions.some(
(s: any) => s.userId === userUser.id,
);
if (hasUserSub) {
console.log(
"✅ PASS: GET /topics as Admin sees other users subscriptions",
);
} else {
console.error(
"❌ FAIL: GET /topics as Admin did NOT see other users subscriptions",
);
errors++;
}
} catch (e) {
console.error("Test Exception:", e);
errors++;
} finally {
// 4. Cleanup
await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id));
await db.delete(topics).where(eq(topics.id, topic.id));
await db.delete(users).where(eq(users.id, userUser.id));
await db.delete(users).where(eq(users.id, adminUser.id));
console.log("Cleanup Completed");
}
} catch (e) {
console.error('Test Exception:', e);
errors++;
} finally {
// 4. Cleanup
await db.delete(subscriptions).where(eq(subscriptions.topicId, topic.id));
await db.delete(topics).where(eq(topics.id, topic.id));
await db.delete(users).where(eq(users.id, userUser.id));
await db.delete(users).where(eq(users.id, adminUser.id));
console.log('Cleanup Completed');
}
if (errors === 0) {
console.log('🎉 ALL TESTS PASSED');
process.exit(0);
} else {
console.error('💥 SOME TESTS FAILED');
process.exit(1);
}
if (errors === 0) {
console.log("🎉 ALL TESTS PASSED");
process.exit(0);
} else {
console.error("💥 SOME TESTS FAILED");
process.exit(1);
}
}
verify();

View File

@@ -1,311 +1,362 @@
import { Hono } from 'hono';
import { eq } from 'drizzle-orm';
import { db } from './db';
import { topics, alertTasks, alertLogs, users } from './db/schema';
import { feishuClient } from './feishu';
import { logger } from './lib/logger';
import { eq } from "drizzle-orm";
import { Hono } from "hono";
import { db } from "./db";
import { alertLogs, alertTasks, topics, users } from "./db/schema";
import { feishuClient } from "./feishu";
import { logger } from "./lib/logger";
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');
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),
});
// 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);
}
let body;
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);
}
if (!user) {
logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: "Invalid personal token" }, 401);
}
let body;
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
}
});
// 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) {
logger.warn({ slug }, "[Webhook] Topic not found");
return c.json({ error: "Topic not found" }, 404);
}
logger.info({ topicName: topic.name }, '[Webhook] Found topic');
logger.info({ topicName: topic.name }, "[Webhook] Found topic");
// 2. Collect recipients
const userRecipients = topic.subscriptions
.map(sub => sub.user)
.filter(u => !!u && !!u.feishuUserId)
.map(u => ({
type: 'user',
id: u.id,
name: u.name,
feishuId: u.feishuUserId,
idType: u.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id'
}));
// 2. Collect recipients
const userRecipients = topic.subscriptions
.map((sub) => sub.user)
.filter((u) => !!u && !!u.feishuUserId)
.map((u) => ({
type: "user",
id: u.id,
name: u.name,
feishuId: u.feishuUserId,
idType: u.feishuUserId.startsWith("ou_") ? "open_id" : "user_id",
}));
const groupRecipients = topic.groupChats.map(g => ({
type: 'group',
id: g.id, // Binding ID
name: g.name,
feishuId: g.chatId,
idType: 'chat_id'
}));
const groupRecipients = topic.groupChats.map((g) => ({
type: "group",
id: g.id, // Binding ID
name: g.name,
feishuId: g.chatId,
idType: "chat_id",
}));
const allRecipients = [...userRecipients, ...groupRecipients];
const allRecipients = [...userRecipients, ...groupRecipients];
const [task] = await db.insert(alertTasks).values({
topicSlug: topic.slug,
senderId: user.id,
status: 'processing',
recipientCount: allRecipients.length,
successCount: 0,
payload: body,
}).returning();
const [task] = await db
.insert(alertTasks)
.values({
topicSlug: topic.slug,
senderId: user.id,
status: "processing",
recipientCount: allRecipients.length,
successCount: 0,
payload: body,
})
.returning();
if (allRecipients.length === 0) {
await db.update(alertTasks)
.set({ status: 'completed', updatedAt: new Date() })
.where(eq(alertTasks.id, task.id));
if (allRecipients.length === 0) {
await db
.update(alertTasks)
.set({ status: "completed", updatedAt: new Date() })
.where(eq(alertTasks.id, task.id));
return c.json({
message: 'No subscribers for this topic',
taskId: task.id,
status: 'completed'
});
}
return c.json({
message: "No subscribers for this topic",
taskId: task.id,
status: "completed",
});
}
logger.info({
taskId: task.id,
userCount: userRecipients.length,
groupCount: groupRecipients.length
}, '[Webhook] Dispatching alerts');
logger.info(
{
taskId: task.id,
userCount: userRecipients.length,
groupCount: groupRecipients.length,
},
"[Webhook] Dispatching alerts",
);
// 4. Send Private Messages asynchronously
Promise.allSettled(allRecipients.map(async (recipient) => {
try {
// Construct message content
let msgType = body.msg_type || 'text';
let content = body.content;
// 4. Send Private Messages asynchronously
Promise.allSettled(
allRecipients.map(async (recipient) => {
try {
// Construct message content
let msgType = body.msg_type || "text";
let content = body.content;
if (!content) {
msgType = 'text';
content = { text: JSON.stringify(body, null, 2) };
// Deep copy needed? usually content is new obj if we parsed body
} else {
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
content = JSON.parse(JSON.stringify(content));
}
if (!content) {
msgType = "text";
content = { text: JSON.stringify(body, null, 2) };
// Deep copy needed? usually content is new obj if we parsed body
} else {
// Deep clone content to avoid mutating shared object for parallel requests if we modify it
content = JSON.parse(JSON.stringify(content));
}
// Add metadata
if (msgType === 'text' && content.text) {
content.text = `[Topic: ${topic.name}]\n${content.text}`;
}
if (msgType === 'interactive' && content.header) {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
}
// Add metadata
if (msgType === "text" && content.text) {
content.text = `[Topic: ${topic.name}]\n${content.text}`;
}
if (msgType === "interactive" && content.header) {
content.header.title.content = `[${topic.name}] ${content.header.title.content}`;
}
await feishuClient.sendMessage(recipient.feishuId, recipient.idType as any, msgType, content);
await feishuClient.sendMessage(
recipient.feishuId,
recipient.idType as any,
msgType,
content,
);
return { recipientId: recipient.id, status: 'sent', error: null };
} catch (error: any) {
logger.error({
err: error,
recipientType: recipient.type,
recipientName: recipient.name
}, 'Failed to send alert');
return { recipientId: recipient.id, status: 'failed', error: error.message };
}
})).then(async (results) => {
const successCount = results.filter(r => r.status === 'fulfilled' && (r.value as any).status === 'sent').length;
const failures = results
.filter(r => r.status === 'rejected' || (r.status === 'fulfilled' && (r.value as any).status === 'failed'))
.length;
return { recipientId: recipient.id, status: "sent", error: null };
} catch (error: any) {
logger.error(
{
err: error,
recipientType: recipient.type,
recipientName: recipient.name,
},
"Failed to send alert",
);
return {
recipientId: recipient.id,
status: "failed",
error: error.message,
};
}
}),
).then(async (results) => {
const successCount = results.filter(
(r) => r.status === "fulfilled" && (r.value as any).status === "sent",
).length;
const failures = results.filter(
(r) =>
r.status === "rejected" ||
(r.status === "fulfilled" && (r.value as any).status === "failed"),
).length;
// Determine final status
const finalStatus = failures === 0 ? 'completed' : (successCount > 0 ? 'completed' : 'failed');
// Determine final status
const finalStatus =
failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed";
// Update Task
await db.update(alertTasks).set({
status: finalStatus,
successCount,
updatedAt: new Date(),
// If fully failed, maybe store the first error in the task record for quick view
error: failures > 0 ? `Failed to send to ${failures} recipients` : null,
}).where(eq(alertTasks.id, task.id));
// Update Task
await db
.update(alertTasks)
.set({
status: finalStatus,
successCount,
updatedAt: new Date(),
// If fully failed, maybe store the first error in the task record for quick view
error: failures > 0 ? `Failed to send to ${failures} recipients` : null,
})
.where(eq(alertTasks.id, task.id));
// Insert Logs
const logs = results.map((r, index) => {
const recipient = allRecipients[index];
if (r.status === 'fulfilled') {
const val = r.value as any;
return {
taskId: task.id,
userId: recipient.type === 'user' ? recipient.id : null, // Only link users
// We could add connection to group binding if we altered schema, but for now log it
status: val.status,
error: val.error,
};
} else {
return {
taskId: task.id,
userId: recipient.type === 'user' ? recipient.id : null,
status: 'failed',
error: r.reason ? String(r.reason) : 'Unknown error',
};
}
});
// Insert Logs
const logs = results.map((r, index) => {
const recipient = allRecipients[index];
if (r.status === "fulfilled") {
const val = r.value as any;
return {
taskId: task.id,
userId: recipient.type === "user" ? recipient.id : null, // Only link users
// We could add connection to group binding if we altered schema, but for now log it
status: val.status,
error: val.error,
};
} else {
return {
taskId: task.id,
userId: recipient.type === "user" ? recipient.id : null,
status: "failed",
error: r.reason ? String(r.reason) : "Unknown error",
};
}
});
if (logs.length > 0) {
await db.insert(alertLogs).values(logs as any);
}
if (logs.length > 0) {
await db.insert(alertLogs).values(logs as any);
}
logger.info({
taskId: task.id,
successCount,
totalCount: allRecipients.length,
slug
}, '[Webhook] Task processed');
});
logger.info(
{
taskId: task.id,
successCount,
totalCount: allRecipients.length,
slug,
},
"[Webhook] Task processed",
);
});
return c.json({
message: 'Alert received and processing started',
taskId: task.id,
status: 'processing',
recipientCount: allRecipients.length
});
return c.json({
message: "Alert received and processing started",
taskId: task.id,
status: "processing",
recipientCount: allRecipients.length,
});
});
webhook.post('/:token/dm', async (c) => {
const token = c.req.param('token');
logger.info({ token }, '[Webhook] Received DM request');
webhook.post("/:token/dm", async (c) => {
const token = c.req.param("token");
logger.info({ token }, "[Webhook] Received DM request");
// 0. Find the User by Token
const user = await db.query.users.findFirst({
where: eq(users.personalToken, token),
});
// 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);
}
if (!user) {
logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: "Invalid personal token" }, 401);
}
if (!user.feishuUserId) {
return c.json({ error: 'User has no Feishu ID linked' }, 400);
}
if (!user.feishuUserId) {
return c.json({ error: "User has no Feishu ID linked" }, 400);
}
let body;
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);
}
let body;
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);
}
// 1. Create Task (topicSlug is null for DM)
const [task] = await db.insert(alertTasks).values({
topicSlug: null,
senderId: user.id,
status: 'processing',
recipientCount: 1,
successCount: 0,
payload: body,
}).returning();
// 1. Create Task (topicSlug is null for DM)
const [task] = await db
.insert(alertTasks)
.values({
topicSlug: null,
senderId: user.id,
status: "processing",
recipientCount: 1,
successCount: 0,
payload: body,
})
.returning();
// 2. Send Message
(async () => {
try {
let msgType = body.msg_type || 'text';
let content = body.content;
// 2. Send Message
(async () => {
try {
let msgType = body.msg_type || "text";
let content = body.content;
if (!content) {
msgType = 'text';
content = { text: JSON.stringify(body, null, 2) };
}
if (!content) {
msgType = "text";
content = { text: JSON.stringify(body, null, 2) };
}
// Add metadata
if (msgType === 'text' && content.text) {
content.text = `[Direct Message]\n${content.text}`;
}
if (msgType === 'interactive' && content.header) {
content.header.title.content = `[DM] ${content.header.title.content}`;
}
// Add metadata
if (msgType === "text" && content.text) {
content.text = `[Direct Message]\n${content.text}`;
}
if (msgType === "interactive" && content.header) {
content.header.title.content = `[DM] ${content.header.title.content}`;
}
const idType = user.feishuUserId.startsWith('ou_') ? 'open_id' : 'user_id';
await feishuClient.sendMessage(user.feishuUserId, idType, msgType, content);
const idType = user.feishuUserId.startsWith("ou_")
? "open_id"
: "user_id";
await feishuClient.sendMessage(
user.feishuUserId,
idType,
msgType,
content,
);
// Update Task
await db.update(alertTasks).set({
status: 'completed',
successCount: 1,
updatedAt: new Date(),
}).where(eq(alertTasks.id, task.id));
// Update Task
await db
.update(alertTasks)
.set({
status: "completed",
successCount: 1,
updatedAt: new Date(),
})
.where(eq(alertTasks.id, task.id));
// Insert Log
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: 'sent',
});
// Insert Log
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: "sent",
});
} catch (error: any) {
logger.error({ err: error, userName: user.name }, "Failed to send DM");
await db
.update(alertTasks)
.set({
status: "failed",
updatedAt: new Date(),
error: error.message,
})
.where(eq(alertTasks.id, task.id));
} catch (error: any) {
logger.error({ err: error, userName: user.name }, 'Failed to send DM');
await db.update(alertTasks).set({
status: 'failed',
updatedAt: new Date(),
error: error.message,
}).where(eq(alertTasks.id, task.id));
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: "failed",
error: error.message,
});
}
})();
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: 'failed',
error: error.message,
});
}
})();
return c.json({
message: 'DM received and processing started',
taskId: task.id,
status: 'processing',
recipientCount: 1
});
return c.json({
message: "DM received and processing started",
taskId: task.id,
status: "processing",
recipientCount: 1,
});
});
// Help message for non-POST requests or malformed URLs
webhook.all('/:token/topic/:slug', (c) => {
return c.json({
error: 'Method not allowed',
message: 'Please use POST to send alerts to this webhook',
format: 'POST /webhook/:token/topic/:slug',
example: 'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL'
}, 405);
webhook.all("/:token/topic/:slug", (c) => {
return c.json(
{
error: "Method not allowed",
message: "Please use POST to send alerts to this webhook",
format: "POST /webhook/:token/topic/:slug",
example:
'curl -X POST -H "Content-Type: application/json" -d \'{"content":{"text":"Hello"}}\' URL',
},
405,
);
});
export default webhook;

View File

@@ -1,22 +1,22 @@
import * as lark from '@larksuiteoapi/node-sdk';
import { feishuClient } from './feishu';
import { eventDispatcher } from './event-handler';
import { logger } from './lib/logger';
import * as lark from "@larksuiteoapi/node-sdk";
import { eventDispatcher } from "./event-handler";
import { feishuClient } from "./feishu";
import { logger } from "./lib/logger";
export const startWebSocket = async () => {
if (process.env.FEISHU_USE_WS !== 'true') {
return;
}
if (process.env.FEISHU_USE_WS !== "true") {
return;
}
logger.info('[Feishu WS] Starting WebSocket connection...');
try {
const wsClient = new lark.WSClient({
appId: feishuClient.appId,
appSecret: feishuClient.appSecret,
});
await wsClient.start({ eventDispatcher });
logger.info('[Feishu WS] Connected successfully');
} catch (e) {
logger.error({ err: e }, '[Feishu WS] Connection failed');
}
logger.info("[Feishu WS] Starting WebSocket connection...");
try {
const wsClient = new lark.WSClient({
appId: feishuClient.appId,
appSecret: feishuClient.appSecret,
});
await wsClient.start({ eventDispatcher });
logger.info("[Feishu WS] Connected successfully");
} catch (e) {
logger.error({ err: e }, "[Feishu WS] Connection failed");
}
};