feat: 添加MinIO文件系统支持并优化外部连接器功能
- 添加MinIO用户文件系统配置选项(BEAVER_MINIO_ROOT_USER等) - 更新外部连接器配置结构,包括BASE_URL和认证令牌设置 - 改进connector provider支持更多类型(official, feishu_bot等) - 实现Mistral模型推理模式支持reasoning_effort参数 - 增强外部连接器策略配置和运行时配置管理 - 添加connector bridge事件验证和安全保护机制 - 优化任务路由逻辑,区分simple_chat和new_task场景 - 更新初始技能工具提示配置,分离authoring admin功能
This commit is contained in:
@ -13,7 +13,9 @@ def create_app(*, provider: ConnectorProvider, api_token: str) -> FastAPI:
|
||||
app = FastAPI(title="External Connector")
|
||||
|
||||
def require_auth(authorization: str | None) -> None:
|
||||
if api_token and authorization != f"Bearer {api_token}":
|
||||
if not api_token:
|
||||
raise HTTPException(status_code=503, detail="Connector API token is not configured")
|
||||
if authorization != f"Bearer {api_token}":
|
||||
raise HTTPException(status_code=401, detail="Invalid connector token")
|
||||
|
||||
@app.get("/health")
|
||||
|
||||
216
external-connector/external_connector/node/feishu_event_utils.js
Normal file
216
external-connector/external_connector/node/feishu_event_utils.js
Normal file
@ -0,0 +1,216 @@
|
||||
function bridgeEventFromFeishu(data, env) {
|
||||
const message = objectValue(data.message);
|
||||
const sender = objectValue(data.sender);
|
||||
const senderId = objectValue(sender.sender_id);
|
||||
const isGroup = message.chat_type === "group";
|
||||
const peerId = isGroup
|
||||
? stringValue(message.chat_id || "")
|
||||
: stringValue(senderId.open_id || senderId.user_id || "");
|
||||
const userId = stringValue(senderId.open_id || senderId.user_id || "");
|
||||
const messageId = stringValue(message.message_id || randomId());
|
||||
const eventId = stringValue(data.event_id || data.eventId || `${env.channelId}:${messageId}`);
|
||||
return {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
deliveryAttempt: 1,
|
||||
connectionId: env.connectionId,
|
||||
channelId: env.channelId,
|
||||
kind: "feishu",
|
||||
accountId: env.accountId,
|
||||
peerId,
|
||||
peerType: isGroup ? "group" : "dm",
|
||||
userId,
|
||||
threadId: stringValue(message.chat_id || "") || null,
|
||||
messageId,
|
||||
messageType: stringValue(message.message_type || "text"),
|
||||
content: extractText(message),
|
||||
metadata: {
|
||||
chatId: message.chat_id || null,
|
||||
rawMessageType: message.message_type || null,
|
||||
senderType: sender.sender_type || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function bridgeEventFromNormalizedMessage(message, env, options = {}) {
|
||||
const maxMessageChars = positiveInt(options.maxMessageChars, 20000);
|
||||
const content = normalizedContent(message).trim();
|
||||
if (!content || content.length > maxMessageChars) {
|
||||
return null;
|
||||
}
|
||||
const isGroup = message.chatType === "group";
|
||||
const messageId = stringValue(message.messageId || randomId());
|
||||
const chatId = stringValue(message.chatId || "");
|
||||
const senderId = stringValue(message.senderId || "");
|
||||
const eventId = stringValue(rawEventId(message.raw) || `${env.channelId}:${messageId}`);
|
||||
return {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
deliveryAttempt: 1,
|
||||
connectionId: env.connectionId,
|
||||
channelId: env.channelId,
|
||||
kind: "feishu",
|
||||
accountId: env.accountId,
|
||||
peerId: isGroup ? chatId : senderId,
|
||||
peerType: isGroup ? "group" : "dm",
|
||||
userId: senderId,
|
||||
threadId: chatId || null,
|
||||
messageId,
|
||||
messageType: stringValue(message.rawContentType || "text"),
|
||||
content,
|
||||
metadata: {
|
||||
chatId: chatId || null,
|
||||
rawMessageType: message.rawContentType || null,
|
||||
senderName: message.senderName || null,
|
||||
mentions: Array.isArray(message.mentions) ? message.mentions : [],
|
||||
mentionAll: Boolean(message.mentionAll),
|
||||
mentionedBot: Boolean(message.mentionedBot),
|
||||
resources: Array.isArray(message.resources) ? message.resources : [],
|
||||
createTime: message.createTime || null,
|
||||
rootId: message.rootId || null,
|
||||
threadId: message.threadId || null,
|
||||
replyToMessageId: message.replyToMessageId || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildChannelOptions({ appId, appSecret, domain, policy }) {
|
||||
const resolvedPolicy = policy || parsePolicyEnv(process.env);
|
||||
return {
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
includeRawEvent: true,
|
||||
source: "beaver",
|
||||
handshakeTimeoutMs: 15000,
|
||||
wsConfig: { pingTimeout: 10 },
|
||||
policy: {
|
||||
requireMention: resolvedPolicy.requireMentionInGroups,
|
||||
respondToMentionAll: resolvedPolicy.respondToMentionAll,
|
||||
dmMode: resolvedPolicy.dmMode,
|
||||
dmAllowlist: resolvedPolicy.allowFrom,
|
||||
groupAllowlist: resolvedPolicy.groupAllowFrom,
|
||||
},
|
||||
safety: {
|
||||
chatQueue: { enabled: true },
|
||||
staleMessageWindowMs: positiveInt(resolvedPolicy.staleMessageWindowMs, 10 * 60 * 1000),
|
||||
batch: {
|
||||
text: {
|
||||
delayMs: positiveInt(resolvedPolicy.textBatchDelayMs, 0),
|
||||
maxMessages: positiveInt(resolvedPolicy.textBatchMaxMessages, 10),
|
||||
maxChars: positiveInt(resolvedPolicy.textBatchMaxChars, resolvedPolicy.maxMessageChars),
|
||||
},
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
textChunkLimit: resolvedPolicy.maxMessageChars,
|
||||
retry: { maxAttempts: 3, baseDelayMs: 500 },
|
||||
ssrfGuard: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parsePolicyEnv(env) {
|
||||
return {
|
||||
requireMentionInGroups: envBool(env.FEISHU_REQUIRE_MENTION_IN_GROUPS, true),
|
||||
respondToMentionAll: envBool(env.FEISHU_RESPOND_TO_MENTION_ALL, false),
|
||||
dmMode: stringValue(env.FEISHU_DM_MODE || "open") || "open",
|
||||
allowFrom: envList(env.FEISHU_ALLOW_FROM || env.FEISHU_DM_ALLOW_FROM || ""),
|
||||
groupAllowFrom: envList(env.FEISHU_GROUP_ALLOW_FROM || ""),
|
||||
maxMessageChars: positiveInt(env.FEISHU_MAX_MESSAGE_CHARS, 20000),
|
||||
textBatchDelayMs: positiveInt(env.FEISHU_TEXT_BATCH_DELAY_MS, 0),
|
||||
textBatchMaxMessages: positiveInt(env.FEISHU_TEXT_BATCH_MAX_MESSAGES, 10),
|
||||
textBatchMaxChars: positiveInt(env.FEISHU_TEXT_BATCH_MAX_CHARS, 20000),
|
||||
staleMessageWindowMs: positiveInt(env.FEISHU_STALE_MESSAGE_WINDOW_MS, 10 * 60 * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function ignoreReason(data) {
|
||||
const sender = objectValue(data.sender);
|
||||
const senderType = stringValue(sender.sender_type || "").toLowerCase();
|
||||
if (senderType && senderType !== "user") {
|
||||
return `sender_type:${senderType}`;
|
||||
}
|
||||
const content = extractText(objectValue(data.message)).trim();
|
||||
if (content.startsWith("/feishu")) {
|
||||
return "feishu_command";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizedContent(message) {
|
||||
const parts = [stringValue(message.content || "")];
|
||||
const resources = Array.isArray(message.resources) ? message.resources : [];
|
||||
for (const resource of resources) {
|
||||
if (!resource || typeof resource !== "object") {
|
||||
continue;
|
||||
}
|
||||
const type = stringValue(resource.type || "file");
|
||||
const fileName = stringValue(resource.fileName || "");
|
||||
parts.push(`[${type}${fileName ? `: ${fileName}` : ""}]`);
|
||||
}
|
||||
return parts.filter((part) => part.trim()).join("\n");
|
||||
}
|
||||
|
||||
function rawEventId(raw) {
|
||||
const rawObject = objectValue(raw);
|
||||
const header = objectValue(rawObject.header);
|
||||
return rawObject.event_id || rawObject.eventId || header.event_id || header.eventId || "";
|
||||
}
|
||||
|
||||
function extractText(message) {
|
||||
const content = message.content;
|
||||
if (typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && parsed.text != null) {
|
||||
return String(parsed.text);
|
||||
}
|
||||
} catch (_error) {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
function envList(value) {
|
||||
return stringValue(value)
|
||||
.replace(/\n/g, ",")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function envBool(value, defaultValue) {
|
||||
if (value == null || value === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
return ["1", "true", "yes", "on"].includes(String(value).trim().toLowerCase());
|
||||
}
|
||||
|
||||
function positiveInt(value, defaultValue) {
|
||||
const number = Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(number) && number > 0 ? number : defaultValue;
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
return value && typeof value === "object" ? value : {};
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bridgeEventFromFeishu,
|
||||
bridgeEventFromNormalizedMessage,
|
||||
buildChannelOptions,
|
||||
ignoreReason,
|
||||
extractText,
|
||||
parsePolicyEnv,
|
||||
};
|
||||
@ -1,4 +1,9 @@
|
||||
const Lark = require("@larksuiteoapi/node-sdk");
|
||||
const {
|
||||
bridgeEventFromNormalizedMessage,
|
||||
buildChannelOptions,
|
||||
parsePolicyEnv,
|
||||
} = require("./feishu_event_utils");
|
||||
|
||||
const appId = requireEnv("FEISHU_APP_ID");
|
||||
const appSecret = requireEnv("FEISHU_APP_SECRET");
|
||||
@ -10,99 +15,78 @@ const bridgeToken = requireEnv("BEAVER_BRIDGE_TOKEN");
|
||||
const domain = (process.env.FEISHU_DOMAIN || "feishu").toLowerCase() === "lark"
|
||||
? Lark.Domain.Lark
|
||||
: Lark.Domain.Feishu;
|
||||
const policy = parsePolicyEnv(process.env);
|
||||
const env = { connectionId, channelId, accountId };
|
||||
|
||||
const wsClient = new Lark.WSClient({
|
||||
appId,
|
||||
appSecret,
|
||||
domain,
|
||||
loggerLevel: Lark.LoggerLevel.info,
|
||||
onReady: () => log("feishu_ws_ready", {}),
|
||||
onError: (error) => log("feishu_ws_error", { error: redact(String(error && error.message ? error.message : error)) }),
|
||||
onReconnecting: () => log("feishu_ws_reconnecting", {}),
|
||||
onReconnected: () => log("feishu_ws_reconnected", {}),
|
||||
handshakeTimeoutMs: 15000,
|
||||
wsConfig: { pingTimeout: 10 },
|
||||
});
|
||||
|
||||
const dispatcher = new Lark.EventDispatcher({}).register({
|
||||
"im.message.receive_v1": async (data) => {
|
||||
const event = bridgeEventFromFeishu(data);
|
||||
const channel = Lark.createLarkChannel(buildChannelOptions({ appId, appSecret, domain, policy }));
|
||||
channel.on({
|
||||
message: async (message) => {
|
||||
const event = bridgeEventFromNormalizedMessage(message, env, { maxMessageChars: policy.maxMessageChars });
|
||||
if (!event) {
|
||||
log("feishu_inbound_ignored", {
|
||||
connectionId,
|
||||
messageId: message.messageId,
|
||||
reason: "empty_or_oversized",
|
||||
});
|
||||
return;
|
||||
}
|
||||
log("feishu_inbound_message", {
|
||||
connectionId,
|
||||
eventId: event.eventId,
|
||||
messageId: event.messageId,
|
||||
peerId: event.peerId,
|
||||
peerType: event.peerType,
|
||||
textLength: event.content.length,
|
||||
});
|
||||
await postJson(`${bridgeBaseUrl}/api/channel-connector-bridge/events`, event);
|
||||
},
|
||||
reject: (event) => {
|
||||
log("feishu_inbound_ignored", {
|
||||
connectionId,
|
||||
messageId: event.messageId,
|
||||
peerId: event.chatId,
|
||||
userId: event.senderId,
|
||||
reason: event.reason,
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
log("feishu_ws_error", {
|
||||
connectionId,
|
||||
code: error.code || "unknown",
|
||||
error: redact(String(error && error.message ? error.message : error)),
|
||||
});
|
||||
},
|
||||
reconnecting: () => log("feishu_ws_reconnecting", { connectionId }),
|
||||
reconnected: () => log("feishu_ws_reconnected", { connectionId }),
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
wsClient.close({ force: true });
|
||||
process.exit(0);
|
||||
channel.disconnect().finally(() => process.exit(0));
|
||||
});
|
||||
process.on("SIGINT", () => {
|
||||
wsClient.close({ force: true });
|
||||
process.exit(0);
|
||||
channel.disconnect().finally(() => process.exit(0));
|
||||
});
|
||||
|
||||
wsClient.start({ eventDispatcher: dispatcher }).catch((error) => {
|
||||
channel.connect().then(() => {
|
||||
log("feishu_ws_ready", {
|
||||
connectionId,
|
||||
requireMentionInGroups: policy.requireMentionInGroups,
|
||||
dmMode: policy.dmMode,
|
||||
groupAllowlistSize: policy.groupAllowFrom.length,
|
||||
dmAllowlistSize: policy.allowFrom.length,
|
||||
});
|
||||
}).catch((error) => {
|
||||
log("feishu_ws_start_failed", { error: redact(String(error && error.message ? error.message : error)) });
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
if (typeof wsClient.getConnectionStatus === "function") {
|
||||
log("feishu_ws_status", wsClient.getConnectionStatus());
|
||||
const status = channel.getConnectionStatus();
|
||||
if (status) {
|
||||
log("feishu_ws_status", { connectionId, ...status });
|
||||
}
|
||||
}, 60000).unref();
|
||||
|
||||
function bridgeEventFromFeishu(data) {
|
||||
const message = objectValue(data.message);
|
||||
const sender = objectValue(data.sender);
|
||||
const senderId = objectValue(sender.sender_id);
|
||||
const peerId = stringValue(senderId.open_id || senderId.user_id || "");
|
||||
const messageId = stringValue(message.message_id || randomId());
|
||||
const eventId = stringValue(data.event_id || data.eventId || `${channelId}:${messageId}`);
|
||||
return {
|
||||
eventId,
|
||||
timestamp: new Date().toISOString(),
|
||||
deliveryAttempt: 1,
|
||||
connectionId,
|
||||
channelId,
|
||||
kind: "feishu",
|
||||
accountId,
|
||||
peerId,
|
||||
peerType: message.chat_type === "group" ? "group" : "dm",
|
||||
userId: peerId,
|
||||
threadId: stringValue(message.chat_id || "") || null,
|
||||
messageId,
|
||||
messageType: stringValue(message.message_type || "text"),
|
||||
content: extractText(message),
|
||||
metadata: {
|
||||
chatId: message.chat_id || null,
|
||||
rawMessageType: message.message_type || null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function extractText(message) {
|
||||
const content = message.content;
|
||||
if (typeof content !== "string") {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
if (parsed && parsed.text != null) {
|
||||
return String(parsed.text);
|
||||
}
|
||||
} catch (_error) {
|
||||
return content;
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
@ -126,18 +110,6 @@ function requireEnv(name) {
|
||||
return value;
|
||||
}
|
||||
|
||||
function objectValue(value) {
|
||||
return value && typeof value === "object" ? value : {};
|
||||
}
|
||||
|
||||
function stringValue(value) {
|
||||
return value == null ? "" : String(value);
|
||||
}
|
||||
|
||||
function randomId() {
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
function log(event, fields) {
|
||||
console.log(JSON.stringify({ event, ...fields }));
|
||||
}
|
||||
|
||||
@ -89,6 +89,7 @@ class FeishuBotProvider:
|
||||
verification_token = str(options.get("verificationToken") or options.get("verification_token") or "").strip()
|
||||
mode = str(options.get("mode") or "create").strip().lower()
|
||||
domain = _domain(options)
|
||||
metadata.update(_policy_metadata(options))
|
||||
if not app_id or not app_secret:
|
||||
if mode != "link":
|
||||
return self._start_registration_session(session, metadata=metadata, domain=domain)
|
||||
@ -157,34 +158,44 @@ class FeishuBotProvider:
|
||||
target = dict(payload.get("target") or {})
|
||||
metadata = dict(session.metadata)
|
||||
api_base = _open_api_base_url(str(metadata.get("domain") or "feishu"), self.api_base_url)
|
||||
peer_type = str(target.get("peerType") or "dm")
|
||||
receive_id_type = "chat_id" if peer_type == "group" else "open_id"
|
||||
receive_id = str(target.get("threadId") or target.get("peerId") or "") if receive_id_type == "chat_id" else str(target.get("peerId") or "")
|
||||
chunks = _text_chunks(str(payload.get("content") or ""), _positive_int(metadata.get("maxMessageChars"), default=20000))
|
||||
provider_message_ids: list[str] = []
|
||||
try:
|
||||
response = self.http.post(
|
||||
f"{api_base}/open-apis/im/v1/messages?receive_id_type=open_id",
|
||||
json={
|
||||
"receive_id": str(target.get("peerId") or ""),
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": str(payload.get("content") or "")}, ensure_ascii=False),
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
for chunk in chunks:
|
||||
response = self.http.post(
|
||||
f"{api_base}/open-apis/im/v1/messages?receive_id_type={receive_id_type}",
|
||||
json={
|
||||
"receive_id": receive_id,
|
||||
"msg_type": "text",
|
||||
"content": json.dumps({"text": chunk}, ensure_ascii=False),
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = dict(response.json())
|
||||
if int(data.get("code") or 0) != 0:
|
||||
error = str(data.get("msg") or data)
|
||||
self.store.fail_send(begin.dedupe_key, error=error)
|
||||
return {"ok": False, "error": error, "httpStatus": 502}
|
||||
provider_message_ids.append(str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}"))
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
self.store.fail_send(begin.dedupe_key, error=error)
|
||||
return {"ok": False, "error": error, "httpStatus": 502}
|
||||
data = dict(response.json())
|
||||
if int(data.get("code") or 0) != 0:
|
||||
error = str(data.get("msg") or data)
|
||||
self.store.fail_send(begin.dedupe_key, error=error)
|
||||
return {"ok": False, "error": error, "httpStatus": 502}
|
||||
provider_message_id = str((data.get("data") or {}).get("message_id") or f"feishu_{payload['requestId']}")
|
||||
provider_message_id = ",".join(provider_message_ids) or f"feishu_{payload['requestId']}"
|
||||
self.store.complete_send(begin.dedupe_key, provider_message_id=provider_message_id)
|
||||
return {"ok": True, "providerMessageId": provider_message_id}
|
||||
|
||||
def handle_event(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
challenge = payload.get("challenge")
|
||||
if challenge:
|
||||
token = str(payload.get("token") or (payload.get("header") or {}).get("token") or "")
|
||||
if not token or self._session_for_verification_token(token) is None:
|
||||
return {"ok": False, "error": "verification token is required", "httpStatus": 401}
|
||||
return {"challenge": challenge}
|
||||
header = dict(payload.get("header") or {})
|
||||
event = dict(payload.get("event") or {})
|
||||
@ -192,9 +203,14 @@ class FeishuBotProvider:
|
||||
session = self._session_for_app_id(app_id)
|
||||
expected_token = str(session.metadata.get("verificationToken") or "")
|
||||
received_token = str(header.get("token") or payload.get("token") or "")
|
||||
if expected_token and received_token != expected_token:
|
||||
if not expected_token or received_token != expected_token:
|
||||
return {"ok": False, "error": "invalid verification token", "httpStatus": 401}
|
||||
ignored = _ignore_reason(event)
|
||||
if ignored:
|
||||
return {"ok": True, "ignored": ignored}
|
||||
bridge_event = _bridge_event_from_feishu(session, header, event)
|
||||
if bridge_event is None:
|
||||
return {"ok": True, "ignored": "empty_or_oversized"}
|
||||
self.bridge_post(
|
||||
f"{self.bridge_base_url}/api/channel-connector-bridge/events",
|
||||
bridge_event,
|
||||
@ -234,6 +250,18 @@ class FeishuBotProvider:
|
||||
return session
|
||||
raise KeyError(app_id)
|
||||
|
||||
def _session_for_verification_token(self, token: str) -> ConnectorSessionState | None:
|
||||
sessions = self.store.list_sessions()
|
||||
for session in sorted(sessions, key=lambda item: item.updated_at, reverse=True):
|
||||
if (
|
||||
session.kind == "feishu"
|
||||
and session.status == "connected"
|
||||
and token
|
||||
and session.metadata.get("verificationToken") == token
|
||||
):
|
||||
return session
|
||||
return None
|
||||
|
||||
def _default_bridge_post(self, url: str, payload: dict[str, Any], headers: dict[str, str]) -> None:
|
||||
response = self.http.post(url, json=payload, headers=headers, timeout=20)
|
||||
response.raise_for_status()
|
||||
@ -415,6 +443,15 @@ class FeishuBotProvider:
|
||||
"FEISHU_CONNECTION_ID": session.connection_id,
|
||||
"FEISHU_CHANNEL_ID": session.channel_id,
|
||||
"FEISHU_ACCOUNT_ID": str(session.account_id or ""),
|
||||
"FEISHU_REQUIRE_MENTION_IN_GROUPS": _env_bool(metadata.get("requireMentionInGroups"), default=True),
|
||||
"FEISHU_RESPOND_TO_MENTION_ALL": _env_bool(metadata.get("respondToMentionAll"), default=False),
|
||||
"FEISHU_DM_MODE": str(metadata.get("dmMode") or "open"),
|
||||
"FEISHU_ALLOW_FROM": ",".join(_string_list(metadata.get("allowFrom"))),
|
||||
"FEISHU_GROUP_ALLOW_FROM": ",".join(_string_list(metadata.get("groupAllowFrom"))),
|
||||
"FEISHU_MAX_MESSAGE_CHARS": str(_positive_int(metadata.get("maxMessageChars"), default=20000)),
|
||||
"FEISHU_TEXT_BATCH_DELAY_MS": str(_positive_int(metadata.get("textBatchDelayMs"), default=0)),
|
||||
"FEISHU_TEXT_BATCH_MAX_MESSAGES": str(_positive_int(metadata.get("textBatchMaxMessages"), default=10)),
|
||||
"FEISHU_TEXT_BATCH_MAX_CHARS": str(_positive_int(metadata.get("textBatchMaxChars"), default=20000)),
|
||||
"BEAVER_BRIDGE_BASE_URL": self.bridge_base_url,
|
||||
"BEAVER_BRIDGE_TOKEN": self.bridge_token,
|
||||
}
|
||||
@ -422,13 +459,20 @@ class FeishuBotProvider:
|
||||
return subprocess.Popen(["node", str(script)], env=env, cwd=str(script.parent))
|
||||
|
||||
|
||||
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any]:
|
||||
def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str, Any], event: dict[str, Any]) -> dict[str, Any] | None:
|
||||
message = dict(event.get("message") or {})
|
||||
sender = dict(event.get("sender") or {})
|
||||
sender_id = dict(sender.get("sender_id") or {})
|
||||
peer_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
|
||||
is_group = message.get("chat_type") == "group"
|
||||
sender_open_id = str(sender_id.get("open_id") or sender_id.get("user_id") or "")
|
||||
chat_id = str(message.get("chat_id") or "")
|
||||
peer_id = chat_id if is_group else sender_open_id
|
||||
message_id = str(message.get("message_id") or uuid4().hex)
|
||||
event_id = str(header.get("event_id") or f"{session.channel_id}:{message_id}")
|
||||
content = _extract_text(message).strip()
|
||||
max_chars = _positive_int(session.metadata.get("maxMessageChars"), default=20000)
|
||||
if not content or len(content) > max_chars:
|
||||
return None
|
||||
return {
|
||||
"eventId": event_id,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
@ -438,13 +482,18 @@ def _bridge_event_from_feishu(session: ConnectorSessionState, header: dict[str,
|
||||
"kind": "feishu",
|
||||
"accountId": session.account_id,
|
||||
"peerId": peer_id,
|
||||
"peerType": "group" if message.get("chat_type") == "group" else "dm",
|
||||
"userId": peer_id,
|
||||
"threadId": str(message.get("chat_id") or "") or None,
|
||||
"peerType": "group" if is_group else "dm",
|
||||
"userId": sender_open_id,
|
||||
"threadId": chat_id or None,
|
||||
"messageId": message_id,
|
||||
"messageType": str(message.get("message_type") or "text"),
|
||||
"content": _extract_text(message),
|
||||
"metadata": {"chatId": message.get("chat_id"), "rawMessageType": message.get("message_type")},
|
||||
"content": content,
|
||||
"metadata": {
|
||||
"chatId": message.get("chat_id"),
|
||||
"rawMessageType": message.get("message_type"),
|
||||
"senderType": sender.get("sender_type"),
|
||||
"mentions": message.get("mentions") if isinstance(message.get("mentions"), list) else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -480,6 +529,97 @@ def _extract_text(message: dict[str, Any]) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _text_chunks(text: str, max_chars: int) -> list[str]:
|
||||
cleaned = str(text or "")
|
||||
if not cleaned:
|
||||
return [""]
|
||||
size = max(1, int(max_chars))
|
||||
return [cleaned[index : index + size] for index in range(0, len(cleaned), size)]
|
||||
|
||||
|
||||
def _ignore_reason(event: dict[str, Any]) -> str:
|
||||
sender = dict(event.get("sender") or {})
|
||||
sender_type = str(sender.get("sender_type") or "").strip().lower()
|
||||
if sender_type and sender_type != "user":
|
||||
return f"sender_type:{sender_type}"
|
||||
message = dict(event.get("message") or {})
|
||||
content = _extract_text(message).strip()
|
||||
if content.startswith("/feishu"):
|
||||
return "feishu_command"
|
||||
return ""
|
||||
|
||||
|
||||
def _policy_metadata(options: dict[str, Any]) -> dict[str, Any]:
|
||||
metadata: dict[str, Any] = {}
|
||||
for key in ("allowFrom", "allow_from"):
|
||||
items = _string_list(options.get(key))
|
||||
if items:
|
||||
metadata["allowFrom"] = items
|
||||
break
|
||||
for key in ("groupAllowFrom", "group_allow_from"):
|
||||
items = _string_list(options.get(key))
|
||||
if items:
|
||||
metadata["groupAllowFrom"] = items
|
||||
break
|
||||
if "requireMentionInGroups" in options or "require_mention_in_groups" in options:
|
||||
metadata["requireMentionInGroups"] = _bool(options.get("requireMentionInGroups", options.get("require_mention_in_groups")))
|
||||
else:
|
||||
metadata["requireMentionInGroups"] = True
|
||||
if "respondToMentionAll" in options or "respond_to_mention_all" in options:
|
||||
metadata["respondToMentionAll"] = _bool(options.get("respondToMentionAll", options.get("respond_to_mention_all")))
|
||||
dm_mode = str(options.get("dmMode") or options.get("dm_mode") or "open").strip()
|
||||
metadata["dmMode"] = dm_mode if dm_mode in {"open", "allowlist", "pair", "disabled"} else "open"
|
||||
for key, metadata_key, default in (
|
||||
("maxMessageChars", "maxMessageChars", 20000),
|
||||
("textBatchDelayMs", "textBatchDelayMs", 0),
|
||||
("textBatchMaxMessages", "textBatchMaxMessages", 10),
|
||||
("textBatchMaxChars", "textBatchMaxChars", 20000),
|
||||
):
|
||||
alt_key = _camel_to_snake(key)
|
||||
if key in options or alt_key in options:
|
||||
metadata[metadata_key] = _positive_int(options.get(key, options.get(alt_key)), default=default)
|
||||
return metadata
|
||||
|
||||
|
||||
def _string_list(value: Any) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
raw_items = value.replace("\n", ",").split(",")
|
||||
elif isinstance(value, list):
|
||||
raw_items = value
|
||||
else:
|
||||
raw_items = []
|
||||
return [str(item).strip() for item in raw_items if str(item).strip()]
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _positive_int(value: Any, *, default: int) -> int:
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return number if number > 0 else default
|
||||
|
||||
|
||||
def _env_bool(value: Any, *, default: bool) -> str:
|
||||
if value is None:
|
||||
return "true" if default else "false"
|
||||
return "true" if _bool(value) else "false"
|
||||
|
||||
|
||||
def _camel_to_snake(value: str) -> str:
|
||||
result: list[str] = []
|
||||
for char in value:
|
||||
if char.isupper() and result:
|
||||
result.append("_")
|
||||
result.append(char.lower())
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _domain(options: dict[str, Any]) -> str:
|
||||
domain = str(options.get("domain") or "feishu").strip().lower()
|
||||
return "lark" if domain == "lark" else "feishu"
|
||||
|
||||
@ -45,6 +45,7 @@ class WeixinIlinkProvider:
|
||||
self.start_receivers = start_receivers
|
||||
self._receiver_stops: dict[str, threading.Event] = {}
|
||||
self._receiver_lock = threading.Lock()
|
||||
self._start_existing_connected_receivers()
|
||||
|
||||
def connectors(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
@ -303,13 +304,45 @@ class WeixinIlinkProvider:
|
||||
self._receiver_stops[session.connection_id] = stop
|
||||
thread = threading.Thread(target=self._receiver_loop, args=(session.connection_id, stop), daemon=True)
|
||||
thread.start()
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "weixin_receiver_started",
|
||||
"connectionId": session.connection_id,
|
||||
"channelId": session.channel_id,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def _start_existing_connected_receivers(self) -> None:
|
||||
if not self.start_receivers:
|
||||
return None
|
||||
for session in self.store.list_sessions():
|
||||
if session.kind != "weixin" or session.status != "connected":
|
||||
continue
|
||||
if _has_connection_material(session):
|
||||
self._ensure_receiver(session)
|
||||
return None
|
||||
|
||||
def _receiver_loop(self, connection_id: str, stop: threading.Event) -> None:
|
||||
while not stop.is_set():
|
||||
try:
|
||||
self.poll_once(connection_id)
|
||||
except Exception:
|
||||
except Exception as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "weixin_receiver_error",
|
||||
"connectionId": connection_id,
|
||||
"error": str(exc)[:300],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
time.sleep(5)
|
||||
stop.wait(1)
|
||||
|
||||
|
||||
189
external-connector/tests/node/feishu_event_utils.test.js
Normal file
189
external-connector/tests/node/feishu_event_utils.test.js
Normal file
@ -0,0 +1,189 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
bridgeEventFromFeishu,
|
||||
bridgeEventFromNormalizedMessage,
|
||||
buildChannelOptions,
|
||||
ignoreReason,
|
||||
parsePolicyEnv,
|
||||
} = require("../../external_connector/node/feishu_event_utils");
|
||||
|
||||
test("ignores Feishu app or bot sender events", () => {
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "app" }, message: { content: "{\"text\":\"hello\"}" } }), "sender_type:app");
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "bot" }, message: { content: "{\"text\":\"hello\"}" } }), "sender_type:bot");
|
||||
});
|
||||
|
||||
test("ignores Feishu slash commands intended for the platform integration", () => {
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "user" }, message: { content: "{\"text\":\"/feishu start\"}" } }), "feishu_command");
|
||||
});
|
||||
|
||||
test("keeps user messages and records sender type metadata", () => {
|
||||
const event = bridgeEventFromFeishu(
|
||||
{
|
||||
event_id: "evt_1",
|
||||
sender: { sender_type: "user", sender_id: { open_id: "ou_user" } },
|
||||
message: {
|
||||
message_id: "om_1",
|
||||
chat_id: "oc_1",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: "{\"text\":\"hello\"}",
|
||||
},
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
);
|
||||
|
||||
assert.equal(ignoreReason({ sender: { sender_type: "user" }, message: { content: "{\"text\":\"hello\"}" } }), "");
|
||||
assert.equal(event.content, "hello");
|
||||
assert.equal(event.peerId, "ou_user");
|
||||
assert.equal(event.metadata.senderType, "user");
|
||||
});
|
||||
|
||||
test("uses chat id as peer id for group messages", () => {
|
||||
const event = bridgeEventFromFeishu(
|
||||
{
|
||||
event_id: "evt_1",
|
||||
sender: { sender_type: "user", sender_id: { open_id: "ou_user" } },
|
||||
message: {
|
||||
message_id: "om_1",
|
||||
chat_id: "oc_group",
|
||||
chat_type: "group",
|
||||
message_type: "text",
|
||||
content: "{\"text\":\"@bot hello\"}",
|
||||
},
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
);
|
||||
|
||||
assert.equal(event.peerType, "group");
|
||||
assert.equal(event.peerId, "oc_group");
|
||||
assert.equal(event.userId, "ou_user");
|
||||
});
|
||||
|
||||
test("builds SDK channel options from explicit Feishu policy environment", () => {
|
||||
const policy = parsePolicyEnv({
|
||||
FEISHU_REQUIRE_MENTION_IN_GROUPS: "false",
|
||||
FEISHU_RESPOND_TO_MENTION_ALL: "true",
|
||||
FEISHU_DM_MODE: "allowlist",
|
||||
FEISHU_ALLOW_FROM: "ou_1, ou_2",
|
||||
FEISHU_GROUP_ALLOW_FROM: "oc_1\noc_2",
|
||||
FEISHU_MAX_MESSAGE_CHARS: "1234",
|
||||
FEISHU_TEXT_BATCH_DELAY_MS: "250",
|
||||
FEISHU_TEXT_BATCH_MAX_MESSAGES: "5",
|
||||
FEISHU_TEXT_BATCH_MAX_CHARS: "2048",
|
||||
});
|
||||
const options = buildChannelOptions({
|
||||
appId: "cli_1",
|
||||
appSecret: "secret",
|
||||
domain: "feishu",
|
||||
policy,
|
||||
});
|
||||
|
||||
assert.equal(options.policy.requireMention, false);
|
||||
assert.equal(options.policy.respondToMentionAll, true);
|
||||
assert.equal(options.policy.dmMode, "allowlist");
|
||||
assert.deepEqual(options.policy.dmAllowlist, ["ou_1", "ou_2"]);
|
||||
assert.deepEqual(options.policy.groupAllowlist, ["oc_1", "oc_2"]);
|
||||
assert.equal(options.outbound.textChunkLimit, 1234);
|
||||
assert.equal(options.safety.batch.text.delayMs, 250);
|
||||
assert.equal(options.safety.batch.text.maxMessages, 5);
|
||||
assert.equal(options.safety.batch.text.maxChars, 2048);
|
||||
assert.equal(options.includeRawEvent, true);
|
||||
});
|
||||
|
||||
test("normalizes SDK message events for Beaver bridge", () => {
|
||||
const event = bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_1",
|
||||
chatId: "oc_group",
|
||||
chatType: "group",
|
||||
senderId: "ou_user",
|
||||
content: "hello",
|
||||
rawContentType: "text",
|
||||
resources: [{ type: "image", fileKey: "img_1", fileName: "photo.png" }],
|
||||
mentions: [{ openId: "ou_bot", name: "Beaver", isBot: true }],
|
||||
mentionAll: false,
|
||||
mentionedBot: true,
|
||||
createTime: 1710000000000,
|
||||
raw: { event_id: "evt_1" },
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
{ maxMessageChars: 100 },
|
||||
);
|
||||
|
||||
assert.equal(event.eventId, "evt_1");
|
||||
assert.equal(event.peerType, "group");
|
||||
assert.equal(event.peerId, "oc_group");
|
||||
assert.equal(event.userId, "ou_user");
|
||||
assert.equal(event.threadId, "oc_group");
|
||||
assert.match(event.content, /^hello/);
|
||||
assert.deepEqual(event.metadata.mentions[0].openId, "ou_bot");
|
||||
assert.deepEqual(event.metadata.resources[0].type, "image");
|
||||
});
|
||||
|
||||
test("uses sender id as peer id for SDK direct messages", () => {
|
||||
const event = bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_dm",
|
||||
chatId: "oc_dm",
|
||||
chatType: "p2p",
|
||||
senderId: "ou_user",
|
||||
content: "hello dm",
|
||||
rawContentType: "text",
|
||||
resources: [],
|
||||
mentions: [],
|
||||
mentionAll: false,
|
||||
mentionedBot: false,
|
||||
createTime: 1710000000000,
|
||||
raw: { header: { event_id: "evt_dm" } },
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
);
|
||||
|
||||
assert.equal(event.peerType, "dm");
|
||||
assert.equal(event.peerId, "ou_user");
|
||||
assert.equal(event.threadId, "oc_dm");
|
||||
});
|
||||
|
||||
test("drops empty and oversized SDK message events", () => {
|
||||
assert.equal(
|
||||
bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_empty",
|
||||
chatId: "oc_dm",
|
||||
chatType: "p2p",
|
||||
senderId: "ou_user",
|
||||
content: " ",
|
||||
rawContentType: "text",
|
||||
resources: [],
|
||||
mentions: [],
|
||||
mentionAll: false,
|
||||
mentionedBot: false,
|
||||
createTime: 1710000000000,
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
bridgeEventFromNormalizedMessage(
|
||||
{
|
||||
messageId: "om_big",
|
||||
chatId: "oc_dm",
|
||||
chatType: "p2p",
|
||||
senderId: "ou_user",
|
||||
content: "x".repeat(11),
|
||||
rawContentType: "text",
|
||||
resources: [],
|
||||
mentions: [],
|
||||
mentionAll: false,
|
||||
mentionedBot: false,
|
||||
createTime: 1710000000000,
|
||||
},
|
||||
{ connectionId: "conn_1", channelId: "feishu-main", accountId: "feishu:cli_1" },
|
||||
{ maxMessageChars: 10 },
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from urllib.parse import parse_qs
|
||||
|
||||
from external_connector.app import create_app
|
||||
@ -12,6 +13,17 @@ from external_connector.state import SidecarStateStore
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_feishu_node_event_utils() -> None:
|
||||
result = subprocess.run(
|
||||
["node", "--test", "tests/node/feishu_event_utils.test.js"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stdout + result.stderr
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict[str, object]) -> None:
|
||||
self.payload = payload
|
||||
@ -169,6 +181,36 @@ def test_feishu_bot_provider_connects_with_app_credentials(tmp_path) -> None:
|
||||
assert receiver_starts == ["conn_1"]
|
||||
|
||||
|
||||
def test_feishu_bot_provider_stores_runtime_policy_options(tmp_path) -> None:
|
||||
receiver_starts: list[str] = []
|
||||
provider = _provider(tmp_path, receiver_starts=receiver_starts)
|
||||
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {
|
||||
"appId": "cli_xxx",
|
||||
"appSecret": "secret",
|
||||
"verificationToken": "verify-token",
|
||||
"requireMentionInGroups": True,
|
||||
"allowFrom": ["ou_1"],
|
||||
"groupAllowFrom": ["oc_1"],
|
||||
"maxMessageChars": 1234,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
stored = provider.store.get_session(session["sessionId"])
|
||||
assert stored.metadata["requireMentionInGroups"] is True
|
||||
assert stored.metadata["allowFrom"] == ["ou_1"]
|
||||
assert stored.metadata["groupAllowFrom"] == ["oc_1"]
|
||||
assert stored.metadata["maxMessageChars"] == 1234
|
||||
|
||||
|
||||
def test_feishu_bot_provider_send_uses_tenant_token_and_dedupes(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
session = provider.start_session(
|
||||
@ -205,13 +247,96 @@ def test_feishu_bot_provider_send_uses_tenant_token_and_dedupes(tmp_path) -> Non
|
||||
assert send_posts[0][1]["msg_type"] == "text"
|
||||
|
||||
|
||||
def test_feishu_event_route_returns_challenge(tmp_path) -> None:
|
||||
def test_feishu_bot_provider_send_uses_chat_id_for_group_targets(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret"},
|
||||
}
|
||||
)
|
||||
|
||||
result = provider.send(
|
||||
{
|
||||
"requestId": "out_group_1",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"kind": "feishu",
|
||||
"target": {"peerId": "oc_group", "peerType": "group", "threadId": "oc_group"},
|
||||
"content": "hello group",
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
send_posts = [item for item in provider.http.posts if "/open-apis/im/v1/messages" in item[0]]
|
||||
assert result["ok"] is True
|
||||
assert send_posts[-1][0].endswith("?receive_id_type=chat_id")
|
||||
assert send_posts[-1][1]["receive_id"] == "oc_group"
|
||||
|
||||
|
||||
def test_feishu_bot_provider_send_chunks_oversized_text(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
session = provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "maxMessageChars": 5},
|
||||
}
|
||||
)
|
||||
|
||||
result = provider.send(
|
||||
{
|
||||
"requestId": "out_chunked",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"kind": "feishu",
|
||||
"target": {"peerId": "ou_user", "peerType": "dm", "threadId": None},
|
||||
"content": "helloworld!",
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
|
||||
send_posts = [item for item in provider.http.posts if "/open-apis/im/v1/messages" in item[0]]
|
||||
contents = [json.loads(item[1]["content"])["text"] for item in send_posts]
|
||||
assert session["status"] == "connected"
|
||||
assert result["ok"] is True
|
||||
assert contents == ["hello", "world", "!"]
|
||||
|
||||
|
||||
def test_feishu_event_route_requires_known_verification_token_for_challenge(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/feishu/events", json={"challenge": "abc"})
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_feishu_event_route_returns_challenge_for_matching_token(tmp_path) -> None:
|
||||
provider = _provider(tmp_path)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
|
||||
}
|
||||
)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.post("/feishu/events", json={"challenge": "abc", "token": "verify-token"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"challenge": "abc"}
|
||||
|
||||
@ -259,6 +384,62 @@ def test_feishu_event_route_forwards_message_to_bridge(tmp_path) -> None:
|
||||
assert bridge_posts[0][1]["peerId"] == "ou_user"
|
||||
|
||||
|
||||
def test_feishu_event_route_ignores_bot_sender_and_platform_commands(tmp_path) -> None:
|
||||
bridge_posts: list[tuple[str, dict[str, object], dict[str, str]]] = []
|
||||
provider = _provider(tmp_path, bridge_posts=bridge_posts)
|
||||
provider.start_session(
|
||||
{
|
||||
"kind": "feishu",
|
||||
"connectionId": "conn_1",
|
||||
"channelId": "feishu-main",
|
||||
"displayName": "Feishu Main",
|
||||
"callbackBaseUrl": "http://beaver:8080",
|
||||
"options": {"appId": "cli_xxx", "appSecret": "secret", "verificationToken": "verify-token"},
|
||||
}
|
||||
)
|
||||
app = create_app(provider=provider, api_token="sidecar-token")
|
||||
|
||||
with TestClient(app) as client:
|
||||
bot = client.post(
|
||||
"/feishu/events",
|
||||
json={
|
||||
"header": {"event_id": "evt_bot", "token": "verify-token", "app_id": "cli_xxx"},
|
||||
"event": {
|
||||
"sender": {"sender_type": "bot", "sender_id": {"open_id": "ou_bot"}},
|
||||
"message": {
|
||||
"message_id": "om_bot",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"hello\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
command = client.post(
|
||||
"/feishu/events",
|
||||
json={
|
||||
"header": {"event_id": "evt_command", "token": "verify-token", "app_id": "cli_xxx"},
|
||||
"event": {
|
||||
"sender": {"sender_type": "user", "sender_id": {"open_id": "ou_user"}},
|
||||
"message": {
|
||||
"message_id": "om_command",
|
||||
"chat_id": "oc_chat",
|
||||
"chat_type": "p2p",
|
||||
"message_type": "text",
|
||||
"content": "{\"text\":\"/feishu start\"}",
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
assert bot.status_code == 200
|
||||
assert command.status_code == 200
|
||||
assert bot.json() == {"ok": True, "ignored": "sender_type:bot"}
|
||||
assert command.json() == {"ok": True, "ignored": "feishu_command"}
|
||||
assert bridge_posts == []
|
||||
|
||||
|
||||
def test_composite_provider_routes_feishu_and_weixin_descriptors(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
provider = CompositeProvider([FakeProvider(store), _provider(tmp_path)])
|
||||
|
||||
@ -70,6 +70,17 @@ def test_sidecar_http_api_requires_bearer_token(tmp_path) -> None:
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_sidecar_http_api_fails_closed_without_configured_token(tmp_path) -> None:
|
||||
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="")
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/connectors")
|
||||
health = client.get("/health")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert health.status_code == 200
|
||||
|
||||
|
||||
def test_sidecar_http_api_session_and_send(tmp_path) -> None:
|
||||
app = create_app(provider=FakeProvider(SidecarStateStore(tmp_path / "state.json")), api_token="sidecar-token")
|
||||
headers = {"Authorization": "Bearer sidecar-token"}
|
||||
|
||||
@ -206,6 +206,38 @@ def test_weixin_ilink_provider_recovers_token_session_persisted_as_scanned(tmp_p
|
||||
assert recovered["accountId"] == "weixin:bot-1@im.bot"
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_starts_existing_connected_receiver_on_startup(tmp_path) -> None:
|
||||
store = SidecarStateStore(tmp_path / "state.json")
|
||||
session = store.create_session(
|
||||
kind="weixin",
|
||||
connection_id="conn_1",
|
||||
channel_id="weixin-main",
|
||||
display_name="Weixin Main",
|
||||
options={},
|
||||
)
|
||||
store.update_session(
|
||||
session.session_id,
|
||||
status="connected",
|
||||
account_id="weixin:bot-1@im.bot",
|
||||
metadata={
|
||||
"token": "bot-token",
|
||||
"baseUrl": "https://api.weixin.example",
|
||||
"userId": "wx-owner",
|
||||
"getUpdatesBuf": "buf",
|
||||
},
|
||||
)
|
||||
|
||||
provider = WeixinIlinkProvider(
|
||||
store=store,
|
||||
http_client=FakeHttpClient(),
|
||||
bridge_base_url="http://beaver:8080",
|
||||
bridge_token="bridge-token",
|
||||
)
|
||||
|
||||
assert "conn_1" in provider._receiver_stops
|
||||
provider.logout("conn_1")
|
||||
|
||||
|
||||
def test_weixin_ilink_provider_send_uses_saved_token_and_dedupes(tmp_path) -> None:
|
||||
http = FakeHttpClient()
|
||||
provider = WeixinIlinkProvider(
|
||||
|
||||
Reference in New Issue
Block a user