feat: 1. db init 2. fault tolerance 3. lint fix

Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
2026-01-15 19:32:24 +08:00
parent fc3435dc80
commit 9965b3e1ce
13 changed files with 310 additions and 122 deletions

View File

@@ -4,12 +4,21 @@ import { db } from "./db";
import { knownGroupChats, topicGroupChats } from "./db/schema";
import { logger } from "./lib/logger";
interface BotAddedEvent {
chat_id: string;
name?: string;
}
interface BotDeletedEvent {
chat_id: string;
}
export const eventDispatcher = new lark.EventDispatcher({
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;
const { chat_id, name } = data as unknown as BotAddedEvent;
logger.info({ chat_id, name }, "[Feishu Event] Bot added to group");
if (chat_id) {
@@ -30,7 +39,7 @@ export const eventDispatcher = new lark.EventDispatcher({
}
},
"im.chat.member.bot.deleted_v1": async (data) => {
const { chat_id } = data as any;
const { chat_id } = data as unknown as BotDeletedEvent;
logger.info({ chat_id }, "[Feishu Event] Bot removed from group");
if (chat_id) {

View File

@@ -1,6 +1,15 @@
import * as lark from "@larksuiteoapi/node-sdk";
import { logger } from "./lib/logger";
export interface UserAccessTokenData {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
refresh_expires_in: number;
scope: string;
}
export class FeishuClient {
public client: lark.Client;
public appId: string;
@@ -20,7 +29,7 @@ export class FeishuClient {
receiveId: string,
receiveIdType: "open_id" | "user_id" | "email" | "chat_id",
msgType: string,
content: any,
content: Record<string, unknown> | string,
) {
// 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'
@@ -50,7 +59,9 @@ export class FeishuClient {
}
}
async getUserAccessToken(code: string): Promise<any> {
async getUserAccessToken(
code: string,
): Promise<UserAccessTokenData | undefined> {
try {
const response = await this.client.authen.accessToken.create({
data: {
@@ -63,7 +74,7 @@ export class FeishuClient {
logger.error({ response }, "Feishu get user access token error");
throw new Error(`Failed to get user access token: ${response.msg}`);
}
return response.data;
return response.data as UserAccessTokenData;
} catch (e) {
console.error("Feishu SDK error:", e);
throw e;

View File

@@ -3,6 +3,11 @@ import { db } from "./db";
import { subscriptions, topics, users } from "./db/schema";
import app from "./index";
interface TopicWithSubscriptions {
id: string;
subscriptions: { userId: string }[];
}
async function verify() {
console.log("Starting Verification...");
let errors = 0;
@@ -112,9 +117,9 @@ async function verify() {
},
});
const res3 = await app.request(req3);
const data3 = await res3.json();
const data3 = (await res3.json()) as TopicWithSubscriptions[];
const targetTopic = (data3 as any).find((t: any) => t.id === topic.id);
const targetTopic = data3.find((t) => t.id === topic.id);
if (targetTopic) {
if (
targetTopic.subscriptions.length === 1 &&
@@ -135,23 +140,19 @@ async function verify() {
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??)
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 data4 = (await res4.json()) as TopicWithSubscriptions[];
const targetTopicAdmin = (data4 as any).find((t: any) => t.id === topic.id);
const targetTopicAdmin = data4.find((t) => t.id === topic.id);
// Should see the subscription for userUser
const hasUserSub = targetTopicAdmin.subscriptions.some(
(s: any) => s.userId === userUser.id,
const hasUserSub = targetTopicAdmin?.subscriptions.some(
(s) => s.userId === userUser.id,
);
if (hasUserSub) {
console.log(

View File

@@ -5,6 +5,16 @@ import { alertLogs, alertTasks, topics, users } from "./db/schema";
import { feishuClient } from "./feishu";
import { logger } from "./lib/logger";
type FeishuReceiveIdType = "open_id" | "user_id" | "email" | "chat_id";
interface Recipient {
type: "user" | "group";
id: string;
name: string;
feishuId: string;
idType: FeishuReceiveIdType;
}
const webhook = new Hono();
webhook.post("/:token/topic/:slug", async (c) => {
@@ -21,7 +31,8 @@ webhook.post("/:token/topic/:slug", async (c) => {
logger.warn({ token }, "[Webhook] Invalid personal token");
return c.json({ error: "Invalid personal token" }, 401);
}
let body: any;
// 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");
@@ -55,26 +66,31 @@ webhook.post("/:token/topic/:slug", async (c) => {
logger.info({ topicName: topic.name }, "[Webhook] Found topic");
// 2. Collect recipients
const userRecipients = topic.subscriptions
const userRecipients: Recipient[] = 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",
}));
.map((u) => {
if (!u || !u.feishuUserId) return null;
return {
type: "user" as const,
id: u.id,
name: u.name,
feishuId: u.feishuUserId,
idType: (u.feishuUserId.startsWith("ou_")
? "open_id"
: "user_id") as FeishuReceiveIdType,
};
})
.filter((u): u is NonNullable<typeof u> => u !== null);
const groupRecipients = topic.groupChats.map((g) => ({
const groupRecipients: Recipient[] = topic.groupChats.map((g) => ({
type: "group",
id: g.id, // Binding ID
name: g.name,
feishuId: g.chatId,
idType: "chat_id",
idType: "chat_id" as FeishuReceiveIdType,
}));
const allRecipients = [...userRecipients, ...groupRecipients];
const allRecipients: Recipient[] = [...userRecipients, ...groupRecipients];
const [task] = await db
.insert(alertTasks)
@@ -137,13 +153,15 @@ webhook.post("/:token/topic/:slug", async (c) => {
await feishuClient.sendMessage(
recipient.feishuId,
recipient.idType as any,
recipient.idType,
msgType,
content,
);
return { recipientId: recipient.id, status: "sent", error: null };
} catch (error: any) {
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(
{
err: error,
@@ -155,22 +173,25 @@ webhook.post("/:token/topic/:slug", async (c) => {
return {
recipientId: recipient.id,
status: "failed",
error: error.message,
error: errorMessage,
};
}
}),
).then(async (results) => {
const successCount = results.filter(
(r) => r.status === "fulfilled" && (r.value as any).status === "sent",
(r) =>
r.status === "fulfilled" &&
(r.value as { status: string }).status === "sent",
).length;
const failures = results.filter(
(r) =>
r.status === "rejected" ||
(r.status === "fulfilled" && (r.value as any).status === "failed"),
(r.status === "fulfilled" &&
(r.value as { status: string }).status === "failed"),
).length;
// Determine final status
const finalStatus =
const finalStatus: "completed" | "failed" =
failures === 0 ? "completed" : successCount > 0 ? "completed" : "failed";
// Update Task
@@ -189,26 +210,29 @@ webhook.post("/:token/topic/:slug", async (c) => {
const logs = results.map((r, index) => {
const recipient = allRecipients[index];
if (r.status === "fulfilled") {
const val = r.value as any;
const val = r.value as {
status: "sent" | "failed";
error: string | null;
};
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,
status: val.status as "sent" | "failed",
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",
status: "failed" as const,
error: r.status === "rejected" ? String(r.reason) : "Unknown error",
};
}
});
if (logs.length > 0) {
await db.insert(alertLogs).values(logs as any);
await db.insert(alertLogs).values(logs);
}
logger.info(
@@ -248,7 +272,8 @@ webhook.post("/:token/dm", async (c) => {
return c.json({ error: "User has no Feishu ID linked" }, 400);
}
let body: any;
// 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() === "") {
@@ -315,24 +340,26 @@ webhook.post("/:token/dm", async (c) => {
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: "sent",
status: "sent" as const,
});
} catch (error: any) {
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error({ err: error, userName: user.name }, "Failed to send DM");
await db
.update(alertTasks)
.set({
status: "failed",
updatedAt: new Date(),
error: error.message,
error: errorMessage,
})
.where(eq(alertTasks.id, task.id));
await db.insert(alertLogs).values({
taskId: task.id,
userId: user.id,
status: "failed",
error: error.message,
status: "failed" as const,
error: errorMessage,
});
}
})();