refactor(feishu): type runtime payload seams

This commit is contained in:
Ayaan Zaidi
2026-03-27 11:24:20 +05:30
parent 9ce2dbe9aa
commit f248fc8f86
4 changed files with 182 additions and 23 deletions

View File

@@ -38,6 +38,10 @@ type FeishuMessageLike = {
export type GroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
type FeishuLogger = {
(...args: unknown[]): void;
};
export type ResolvedFeishuGroupSession = {
peerId: string;
parentPeer: { kind: "group"; id: string } | null;
@@ -182,10 +186,7 @@ function formatSubMessageContent(content: string, contentType: string): string {
}
}
export function parseMergeForwardContent(params: {
content: string;
log?: (...args: any[]) => void;
}): string {
export function parseMergeForwardContent(params: { content: string; log?: FeishuLogger }): string {
const { content, log } = params;
const maxMessages = 50;
log?.("feishu: parsing merge_forward sub-messages from API response");

View File

@@ -31,6 +31,11 @@ export const FEISHU_HTTP_TIMEOUT_MS = 30_000;
export const FEISHU_HTTP_TIMEOUT_MAX_MS = 300_000;
export const FEISHU_HTTP_TIMEOUT_ENV_VAR = "OPENCLAW_FEISHU_HTTP_TIMEOUT_MS";
type FeishuHttpInstanceLike = Pick<
typeof feishuClientSdk.defaultHttpInstance,
"request" | "get" | "post" | "put" | "patch" | "delete" | "head" | "options"
>;
function getWsProxyAgent(): HttpsProxyAgent<string> | undefined {
const proxyUrl =
process.env.https_proxy ||
@@ -66,8 +71,7 @@ function resolveDomain(domain: FeishuDomain | undefined): Lark.Domain | string {
* (e.g. when the Feishu API is slow, causing per-chat queue deadlocks).
*/
function createTimeoutHttpInstance(defaultTimeoutMs: number): Lark.HttpInstance {
const base: Lark.HttpInstance =
feishuClientSdk.defaultHttpInstance as unknown as Lark.HttpInstance;
const base: FeishuHttpInstanceLike = feishuClientSdk.defaultHttpInstance;
function injectTimeout<D>(opts?: Lark.HttpRequestOptions<D>): Lark.HttpRequestOptions<D> {
return { timeout: defaultTimeoutMs, ...opts } as Lark.HttpRequestOptions<D>;

View File

@@ -159,6 +159,142 @@ type RegisterEventHandlersContext = {
fireAndForget?: boolean;
};
type FeishuBotMenuEvent = {
event_key?: string;
timestamp?: string | number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function readString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function readStringOrNumber(value: unknown): string | number | undefined {
return typeof value === "string" || typeof value === "number" ? value : undefined;
}
function parseFeishuMessageEventPayload(value: unknown): FeishuMessageEvent | null {
if (!isRecord(value)) {
return null;
}
const sender = value.sender;
const message = value.message;
if (!isRecord(sender) || !isRecord(message)) {
return null;
}
const senderId = sender.sender_id;
if (!isRecord(senderId)) {
return null;
}
const messageId = readString(message.message_id);
const chatId = readString(message.chat_id);
const chatType = normalizeFeishuChatType(message.chat_type);
const messageType = readString(message.message_type);
const content = readString(message.content);
if (!messageId || !chatId || !chatType || !messageType || !content) {
return null;
}
return value as FeishuMessageEvent;
}
function parseFeishuBotAddedEventPayload(value: unknown): FeishuBotAddedEvent | null {
if (!isRecord(value) || !readString(value.chat_id) || !isRecord(value.operator_id)) {
return null;
}
return value as FeishuBotAddedEvent;
}
function parseFeishuBotRemovedChatId(value: unknown): string | null {
if (!isRecord(value)) {
return null;
}
return readString(value.chat_id) ?? null;
}
function parseFeishuBotMenuEvent(value: unknown): FeishuBotMenuEvent | null {
if (!isRecord(value)) {
return null;
}
const operator = value.operator;
if (operator !== undefined && !isRecord(operator)) {
return null;
}
return {
event_key: readString(value.event_key),
timestamp: readStringOrNumber(value.timestamp),
operator: operator
? {
operator_name: readString(operator.operator_name),
operator_id: isRecord(operator.operator_id)
? {
open_id: readString(operator.operator_id.open_id),
user_id: readString(operator.operator_id.user_id),
union_id: readString(operator.operator_id.union_id),
}
: undefined,
}
: undefined,
};
}
function parseFeishuCardActionEventPayload(value: unknown): FeishuCardActionEvent | null {
if (!isRecord(value)) {
return null;
}
const operator = value.operator;
const action = value.action;
const context = value.context;
if (!isRecord(operator) || !isRecord(action) || !isRecord(context)) {
return null;
}
const token = readString(value.token);
const openId = readString(operator.open_id);
const userId = readString(operator.user_id);
const unionId = readString(operator.union_id);
const tag = readString(action.tag);
const actionValue = action.value;
const contextOpenId = readString(context.open_id);
const contextUserId = readString(context.user_id);
const chatId = readString(context.chat_id);
if (
!token ||
!openId ||
!userId ||
!unionId ||
!tag ||
!isRecord(actionValue) ||
!contextOpenId ||
!contextUserId ||
!chatId
) {
return null;
}
return {
operator: {
open_id: openId,
user_id: userId,
union_id: unionId,
},
token,
action: {
value: actionValue,
tag,
},
context: {
open_id: contextOpenId,
user_id: contextUserId,
chat_id: chatId,
},
};
}
/**
* Per-chat serial queue that ensures messages from the same chat are processed
* in arrival order while allowing different chats to run concurrently.
@@ -410,7 +546,11 @@ function registerEventHandlers(
eventDispatcher.register({
"im.message.receive_v1": async (data) => {
const event = data as unknown as FeishuMessageEvent;
const event = parseFeishuMessageEventPayload(data);
if (!event) {
error(`feishu[${accountId}]: ignoring malformed message event payload`);
return;
}
const messageId = event.message?.message_id?.trim();
if (!tryBeginFeishuMessageProcessing(messageId, accountId)) {
log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
@@ -438,7 +578,10 @@ function registerEventHandlers(
},
"im.chat.member.bot.added_v1": async (data) => {
try {
const event = data as unknown as FeishuBotAddedEvent;
const event = parseFeishuBotAddedEventPayload(data);
if (!event) {
return;
}
log(`feishu[${accountId}]: bot added to chat ${event.chat_id}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot added event: ${String(err)}`);
@@ -446,8 +589,11 @@ function registerEventHandlers(
},
"im.chat.member.bot.deleted_v1": async (data) => {
try {
const event = data as unknown as { chat_id: string };
log(`feishu[${accountId}]: bot removed from chat ${event.chat_id}`);
const chatId = parseFeishuBotRemovedChatId(data);
if (!chatId) {
return;
}
log(`feishu[${accountId}]: bot removed from chat ${chatId}`);
} catch (err) {
error(`feishu[${accountId}]: error handling bot removed event: ${String(err)}`);
}
@@ -513,14 +659,10 @@ function registerEventHandlers(
},
"application.bot.menu_v6": async (data) => {
try {
const event = data as {
event_key?: string;
timestamp?: string | number;
operator?: {
operator_name?: string;
operator_id?: { open_id?: string; user_id?: string; union_id?: string };
};
};
const event = parseFeishuBotMenuEvent(data);
if (!event) {
return;
}
const operatorOpenId = event.operator?.operator_id?.open_id?.trim();
const eventKey = event.event_key?.trim();
if (!operatorOpenId || !eventKey) {
@@ -598,7 +740,11 @@ function registerEventHandlers(
},
"card.action.trigger": async (data: unknown) => {
try {
const event = data as unknown as FeishuCardActionEvent;
const event = parseFeishuCardActionEventPayload(data);
if (!event) {
error(`feishu[${accountId}]: ignoring malformed card action payload`);
return;
}
const promise = handleFeishuCardAction({
cfg,
event,

View File

@@ -24,6 +24,15 @@ type FeishuBotInfoResponse = {
data?: { bot?: { bot_name?: string; open_id?: string } };
};
type FeishuRequestClient = ReturnType<typeof createFeishuClient> & {
request(params: {
method: "GET";
url: string;
data: Record<string, never>;
timeout: number;
}): Promise<FeishuBotInfoResponse>;
};
function setCachedProbeResult(
cacheKey: string,
result: FeishuProbeResult,
@@ -70,16 +79,15 @@ export async function probeFeishu(
}
try {
const client = createFeishuClient(creds);
const client = createFeishuClient(creds) as FeishuRequestClient;
// Use bot/v3/info API to get bot information
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK generic request method
const responseResult = await raceWithTimeoutAndAbort<FeishuBotInfoResponse>(
(client as any).request({
client.request({
method: "GET",
url: "/open-apis/bot/v3/info",
data: {},
timeout: timeoutMs,
}) as Promise<FeishuBotInfoResponse>,
}),
{
timeoutMs,
abortSignal: options.abortSignal,