2026-02-10 15:37:51 -08:00
import type { ClawdbotConfig , PluginRuntime , RuntimeEnv } from "openclaw/plugin-sdk" ;
import { beforeEach , describe , expect , it , vi } from "vitest" ;
import type { FeishuMessageEvent } from "./bot.js" ;
2026-02-26 13:19:17 +01:00
import { buildFeishuAgentBody , handleFeishuMessage } from "./bot.js" ;
2026-02-10 15:37:51 -08:00
import { setFeishuRuntime } from "./runtime.js" ;
2026-02-22 19:21:01 +01:00
const {
mockCreateFeishuReplyDispatcher ,
mockSendMessageFeishu ,
mockGetMessageFeishu ,
mockDownloadMessageResourceFeishu ,
2026-02-26 13:03:29 +01:00
mockCreateFeishuClient ,
2026-02-22 19:21:01 +01:00
} = vi . hoisted ( ( ) = > ( {
mockCreateFeishuReplyDispatcher : vi.fn ( ( ) = > ( {
dispatcher : vi.fn ( ) ,
replyOptions : { } ,
markDispatchIdle : vi.fn ( ) ,
} ) ) ,
mockSendMessageFeishu : vi.fn ( ) . mockResolvedValue ( { messageId : "pairing-msg" , chatId : "oc-dm" } ) ,
mockGetMessageFeishu : vi.fn ( ) . mockResolvedValue ( null ) ,
mockDownloadMessageResourceFeishu : vi.fn ( ) . mockResolvedValue ( {
buffer : Buffer.from ( "video" ) ,
contentType : "video/mp4" ,
fileName : "clip.mp4" ,
2026-02-13 05:43:30 +01:00
} ) ,
2026-02-26 13:03:29 +01:00
mockCreateFeishuClient : vi.fn ( ) ,
2026-02-22 19:21:01 +01:00
} ) ) ;
2026-02-10 15:37:51 -08:00
vi . mock ( "./reply-dispatcher.js" , ( ) = > ( {
createFeishuReplyDispatcher : mockCreateFeishuReplyDispatcher ,
} ) ) ;
2026-02-13 05:43:30 +01:00
vi . mock ( "./send.js" , ( ) = > ( {
sendMessageFeishu : mockSendMessageFeishu ,
getMessageFeishu : mockGetMessageFeishu ,
} ) ) ;
2026-02-22 19:21:01 +01:00
vi . mock ( "./media.js" , ( ) = > ( {
downloadMessageResourceFeishu : mockDownloadMessageResourceFeishu ,
} ) ) ;
2026-02-26 13:03:29 +01:00
vi . mock ( "./client.js" , ( ) = > ( {
createFeishuClient : mockCreateFeishuClient ,
} ) ) ;
2026-02-22 11:28:05 +00:00
function createRuntimeEnv ( ) : RuntimeEnv {
return {
log : vi.fn ( ) ,
error : vi.fn ( ) ,
exit : vi.fn ( ( code : number ) : never = > {
throw new Error ( ` exit ${ code } ` ) ;
} ) ,
} as RuntimeEnv ;
}
async function dispatchMessage ( params : { cfg : ClawdbotConfig ; event : FeishuMessageEvent } ) {
await handleFeishuMessage ( {
cfg : params.cfg ,
event : params.event ,
runtime : createRuntimeEnv ( ) ,
} ) ;
}
2026-02-26 13:19:17 +01:00
describe ( "buildFeishuAgentBody" , ( ) = > {
it ( "builds message id, speaker, quoted content, mentions, and permission notice in order" , ( ) = > {
const body = buildFeishuAgentBody ( {
ctx : {
content : "hello world" ,
senderName : "Sender Name" ,
senderOpenId : "ou-sender" ,
messageId : "msg-42" ,
mentionTargets : [ { openId : "ou-target" , name : "Target User" , key : "@_user_1" } ] ,
} ,
quotedContent : "previous message" ,
permissionErrorForAgent : {
code : 99991672 ,
message : "permission denied" ,
grantUrl : "https://open.feishu.cn/app/cli_test" ,
} ,
} ) ;
expect ( body ) . toBe (
'[message_id: msg-42]\nSender Name: [Replying to: "previous message"]\n\nhello world\n\n[System: Your reply will automatically @mention: Target User. Do not write @xxx yourself.]\n\n[System: The bot encountered a Feishu API permission error. Please inform the user about this issue and provide the permission grant URL for the admin to authorize. Permission grant URL: https://open.feishu.cn/app/cli_test]' ,
) ;
} ) ;
} ) ;
2026-02-10 15:37:51 -08:00
describe ( "handleFeishuMessage command authorization" , ( ) = > {
const mockFinalizeInboundContext = vi . fn ( ( ctx : unknown ) = > ctx ) ;
const mockDispatchReplyFromConfig = vi
. fn ( )
. mockResolvedValue ( { queuedFinal : false , counts : { final : 1 } } ) ;
const mockResolveCommandAuthorizedFromAuthorizers = vi . fn ( ( ) = > false ) ;
const mockShouldComputeCommandAuthorized = vi . fn ( ( ) = > true ) ;
const mockReadAllowFromStore = vi . fn ( ) . mockResolvedValue ( [ ] ) ;
2026-02-13 05:43:30 +01:00
const mockUpsertPairingRequest = vi . fn ( ) . mockResolvedValue ( { code : "ABCDEFGH" , created : false } ) ;
const mockBuildPairingReply = vi . fn ( ( ) = > "Pairing response" ) ;
2026-02-22 19:21:01 +01:00
const mockSaveMediaBuffer = vi . fn ( ) . mockResolvedValue ( {
path : "/tmp/inbound-clip.mp4" ,
contentType : "video/mp4" ,
} ) ;
2026-02-10 15:37:51 -08:00
beforeEach ( ( ) = > {
vi . clearAllMocks ( ) ;
2026-02-26 13:03:29 +01:00
mockCreateFeishuClient . mockReturnValue ( {
contact : {
user : {
get : vi . fn ( ) . mockResolvedValue ( { data : { user : { name : "Sender" } } } ) ,
} ,
} ,
} ) ;
2026-02-10 15:37:51 -08:00
setFeishuRuntime ( {
system : {
enqueueSystemEvent : vi.fn ( ) ,
} ,
channel : {
routing : {
resolveAgentRoute : vi.fn ( ( ) = > ( {
agentId : "main" ,
accountId : "default" ,
sessionKey : "agent:main:feishu:dm:ou-attacker" ,
matchedBy : "default" ,
} ) ) ,
} ,
reply : {
resolveEnvelopeFormatOptions : vi.fn ( ( ) = > ( { template : "channel+name+time" } ) ) ,
formatAgentEnvelope : vi.fn ( ( params : { body : string } ) = > params . body ) ,
finalizeInboundContext : mockFinalizeInboundContext ,
dispatchReplyFromConfig : mockDispatchReplyFromConfig ,
} ,
commands : {
shouldComputeCommandAuthorized : mockShouldComputeCommandAuthorized ,
resolveCommandAuthorizedFromAuthorizers : mockResolveCommandAuthorizedFromAuthorizers ,
} ,
2026-02-22 19:21:01 +01:00
media : {
saveMediaBuffer : mockSaveMediaBuffer ,
} ,
2026-02-10 15:37:51 -08:00
pairing : {
readAllowFromStore : mockReadAllowFromStore ,
2026-02-13 05:43:30 +01:00
upsertPairingRequest : mockUpsertPairingRequest ,
buildPairingReply : mockBuildPairingReply ,
2026-02-10 15:37:51 -08:00
} ,
} ,
2026-02-22 19:21:01 +01:00
media : {
detectMime : vi.fn ( async ( ) = > "application/octet-stream" ) ,
} ,
2026-02-10 15:37:51 -08:00
} as unknown as PluginRuntime ) ;
} ) ;
it ( "uses authorizer resolution instead of hardcoded CommandAuthorized=true" , async ( ) = > {
const cfg : ClawdbotConfig = {
commands : { useAccessGroups : true } ,
channels : {
feishu : {
2026-02-13 05:43:30 +01:00
dmPolicy : "open" ,
2026-02-10 15:37:51 -08:00
allowFrom : [ "ou-admin" ] ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-attacker" ,
} ,
} ,
message : {
message_id : "msg-auth-bypass-regression" ,
chat_id : "oc-dm" ,
chat_type : "p2p" ,
message_type : "text" ,
content : JSON.stringify ( { text : "/status" } ) ,
} ,
} ;
2026-02-22 11:28:05 +00:00
await dispatchMessage ( { cfg , event } ) ;
2026-02-10 15:37:51 -08:00
expect ( mockResolveCommandAuthorizedFromAuthorizers ) . toHaveBeenCalledWith ( {
useAccessGroups : true ,
authorizers : [ { configured : true , allowed : false } ] ,
} ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledWith (
expect . objectContaining ( {
CommandAuthorized : false ,
SenderId : "ou-attacker" ,
Surface : "feishu" ,
} ) ,
) ;
} ) ;
2026-02-13 05:43:30 +01:00
it ( "reads pairing allow store for non-command DMs when dmPolicy is pairing" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
mockReadAllowFromStore . mockResolvedValue ( [ "ou-attacker" ] ) ;
const cfg : ClawdbotConfig = {
commands : { useAccessGroups : true } ,
channels : {
feishu : {
dmPolicy : "pairing" ,
allowFrom : [ ] ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-attacker" ,
} ,
} ,
message : {
message_id : "msg-read-store-non-command" ,
chat_id : "oc-dm" ,
chat_type : "p2p" ,
message_type : "text" ,
content : JSON.stringify ( { text : "hello there" } ) ,
} ,
} ;
2026-02-22 11:28:05 +00:00
await dispatchMessage ( { cfg , event } ) ;
2026-02-13 05:43:30 +01:00
expect ( mockReadAllowFromStore ) . toHaveBeenCalledWith ( "feishu" ) ;
expect ( mockResolveCommandAuthorizedFromAuthorizers ) . not . toHaveBeenCalled ( ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockDispatchReplyFromConfig ) . toHaveBeenCalledTimes ( 1 ) ;
} ) ;
it ( "creates pairing request and drops unauthorized DMs in pairing mode" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
mockReadAllowFromStore . mockResolvedValue ( [ ] ) ;
mockUpsertPairingRequest . mockResolvedValue ( { code : "ABCDEFGH" , created : true } ) ;
const cfg : ClawdbotConfig = {
channels : {
feishu : {
dmPolicy : "pairing" ,
allowFrom : [ ] ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-unapproved" ,
} ,
} ,
message : {
message_id : "msg-pairing-flow" ,
chat_id : "oc-dm" ,
chat_type : "p2p" ,
message_type : "text" ,
content : JSON.stringify ( { text : "hello" } ) ,
} ,
} ;
2026-02-22 11:28:05 +00:00
await dispatchMessage ( { cfg , event } ) ;
2026-02-13 05:43:30 +01:00
expect ( mockUpsertPairingRequest ) . toHaveBeenCalledWith ( {
channel : "feishu" ,
id : "ou-unapproved" ,
meta : { name : undefined } ,
} ) ;
expect ( mockBuildPairingReply ) . toHaveBeenCalledWith ( {
channel : "feishu" ,
idLine : "Your Feishu user id: ou-unapproved" ,
code : "ABCDEFGH" ,
} ) ;
expect ( mockSendMessageFeishu ) . toHaveBeenCalledWith (
expect . objectContaining ( {
to : "user:ou-unapproved" ,
accountId : "default" ,
} ) ,
) ;
expect ( mockFinalizeInboundContext ) . not . toHaveBeenCalled ( ) ;
expect ( mockDispatchReplyFromConfig ) . not . toHaveBeenCalled ( ) ;
} ) ;
it ( "computes group command authorization from group allowFrom" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( true ) ;
mockResolveCommandAuthorizedFromAuthorizers . mockReturnValue ( false ) ;
const cfg : ClawdbotConfig = {
commands : { useAccessGroups : true } ,
channels : {
feishu : {
groups : {
"oc-group" : {
requireMention : false ,
} ,
} ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-attacker" ,
} ,
} ,
message : {
message_id : "msg-group-command-auth" ,
chat_id : "oc-group" ,
chat_type : "group" ,
message_type : "text" ,
content : JSON.stringify ( { text : "/status" } ) ,
} ,
} ;
2026-02-22 11:28:05 +00:00
await dispatchMessage ( { cfg , event } ) ;
2026-02-13 05:43:30 +01:00
expect ( mockResolveCommandAuthorizedFromAuthorizers ) . toHaveBeenCalledWith ( {
useAccessGroups : true ,
authorizers : [ { configured : false , allowed : false } ] ,
} ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledWith (
expect . objectContaining ( {
ChatType : "group" ,
CommandAuthorized : false ,
SenderId : "ou-attacker" ,
} ) ,
) ;
} ) ;
2026-02-22 19:13:04 +01:00
it ( "falls back to top-level allowFrom for group command authorization" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( true ) ;
mockResolveCommandAuthorizedFromAuthorizers . mockReturnValue ( true ) ;
const cfg : ClawdbotConfig = {
commands : { useAccessGroups : true } ,
channels : {
feishu : {
allowFrom : [ "ou-admin" ] ,
groups : {
"oc-group" : {
requireMention : false ,
} ,
} ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-admin" ,
} ,
} ,
message : {
message_id : "msg-group-command-fallback" ,
chat_id : "oc-group" ,
chat_type : "group" ,
message_type : "text" ,
content : JSON.stringify ( { text : "/status" } ) ,
} ,
} ;
await dispatchMessage ( { cfg , event } ) ;
expect ( mockResolveCommandAuthorizedFromAuthorizers ) . toHaveBeenCalledWith ( {
useAccessGroups : true ,
authorizers : [ { configured : true , allowed : true } ] ,
} ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledWith (
expect . objectContaining ( {
ChatType : "group" ,
CommandAuthorized : true ,
SenderId : "ou-admin" ,
} ) ,
) ;
} ) ;
2026-02-22 19:21:01 +01:00
it ( "uses video file_key (not thumbnail image_key) for inbound video download" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
const cfg : ClawdbotConfig = {
channels : {
feishu : {
dmPolicy : "open" ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-sender" ,
} ,
} ,
message : {
message_id : "msg-video-inbound" ,
chat_id : "oc-dm" ,
chat_type : "p2p" ,
message_type : "video" ,
content : JSON.stringify ( {
file_key : "file_video_payload" ,
image_key : "img_thumb_payload" ,
file_name : "clip.mp4" ,
} ) ,
} ,
} ;
await dispatchMessage ( { cfg , event } ) ;
expect ( mockDownloadMessageResourceFeishu ) . toHaveBeenCalledWith (
expect . objectContaining ( {
messageId : "msg-video-inbound" ,
fileKey : "file_video_payload" ,
type : "file" ,
} ) ,
) ;
expect ( mockSaveMediaBuffer ) . toHaveBeenCalledWith (
expect . any ( Buffer ) ,
"video/mp4" ,
"inbound" ,
expect . any ( Number ) ,
"clip.mp4" ,
) ;
} ) ;
2026-02-26 13:01:46 +01:00
it ( "includes message_id in BodyForAgent on its own line" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
const cfg : ClawdbotConfig = {
channels : {
feishu : {
dmPolicy : "open" ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-msgid" ,
} ,
} ,
message : {
message_id : "msg-message-id-line" ,
chat_id : "oc-dm" ,
chat_type : "p2p" ,
message_type : "text" ,
content : JSON.stringify ( { text : "hello" } ) ,
} ,
} ;
await dispatchMessage ( { cfg , event } ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledWith (
expect . objectContaining ( {
BodyForAgent : "[message_id: msg-message-id-line]\nou-msgid: hello" ,
} ) ,
) ;
} ) ;
2026-02-26 13:03:29 +01:00
it ( "dispatches once and appends permission notice to the main agent body" , async ( ) = > {
mockShouldComputeCommandAuthorized . mockReturnValue ( false ) ;
mockCreateFeishuClient . mockReturnValue ( {
contact : {
user : {
get : vi . fn ( ) . mockRejectedValue ( {
response : {
data : {
code : 99991672 ,
msg : "permission denied https://open.feishu.cn/app/cli_test" ,
} ,
} ,
} ) ,
} ,
} ,
} ) ;
const cfg : ClawdbotConfig = {
channels : {
feishu : {
appId : "cli_test" ,
appSecret : "sec_test" ,
groups : {
"oc-group" : {
requireMention : false ,
} ,
} ,
} ,
} ,
} as ClawdbotConfig ;
const event : FeishuMessageEvent = {
sender : {
sender_id : {
open_id : "ou-perm" ,
} ,
} ,
message : {
message_id : "msg-perm-1" ,
chat_id : "oc-group" ,
chat_type : "group" ,
message_type : "text" ,
content : JSON.stringify ( { text : "hello group" } ) ,
} ,
} ;
await dispatchMessage ( { cfg , event } ) ;
expect ( mockDispatchReplyFromConfig ) . toHaveBeenCalledTimes ( 1 ) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledWith (
expect . objectContaining ( {
BodyForAgent : expect.stringContaining (
"Permission grant URL: https://open.feishu.cn/app/cli_test" ,
) ,
} ) ,
) ;
expect ( mockFinalizeInboundContext ) . toHaveBeenCalledWith (
expect . objectContaining ( {
BodyForAgent : expect.stringContaining ( "ou-perm: hello group" ) ,
} ) ,
) ;
} ) ;
2026-02-10 15:37:51 -08:00
} ) ;