mirror of
https://github.com/d0zingcat/alert-message-center.git
synced 2026-06-06 23:16:45 +00:00
feat: 1. db init 2. fault tolerance 3. lint fix
Signed-off-by: d0zingcat <iamtangli42@gmail.com>
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user