Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions adapters/common/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export type TelegramConfig = {
export type FeishuConfig = {
appId: string
appSecret: string
domain: string
encryptKey: string
verificationToken: string
allowedUsers: string[]
Expand Down Expand Up @@ -116,6 +117,7 @@ export function loadConfig(): AdapterConfig {
feishu: {
appId: process.env.FEISHU_APP_ID || fs_.appId || '',
appSecret: process.env.FEISHU_APP_SECRET || fs_.appSecret || '',
domain: process.env.FEISHU_DOMAIN || fs_.domain || 'feishu',
encryptKey: process.env.FEISHU_ENCRYPT_KEY || fs_.encryptKey || '',
verificationToken: process.env.FEISHU_VERIFICATION_TOKEN || fs_.verificationToken || '',
allowedUsers: fs_.allowedUsers ?? [],
Expand Down
6 changes: 4 additions & 2 deletions adapters/feishu/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,13 @@ if (!config.feishu.appId || !config.feishu.appSecret) {
process.exit(1)
}

const larkDomain = config.feishu.domain === 'lark' ? Lark.Domain.Lark : Lark.Domain.Feishu

const larkClient = new Lark.Client({
appId: config.feishu.appId,
appSecret: config.feishu.appSecret,
appType: Lark.AppType.SelfBuild,
domain: Lark.Domain.Feishu,
domain: larkDomain,
})

const bridge = new WsBridge(config.serverUrl, 'feishu')
Expand Down Expand Up @@ -1314,7 +1316,7 @@ async function start(): Promise<void> {
wsClient = new Lark.WSClient({
appId: config.feishu.appId,
appSecret: config.feishu.appSecret,
domain: Lark.Domain.Feishu,
domain: larkDomain,
loggerLevel: Lark.LoggerLevel.info,
})

Expand Down
30 changes: 30 additions & 0 deletions desktop/src/api/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ export type DingtalkRegistrationPoll = {
config?: AdapterFileConfig
}

export type FeishuSetupBegin = {
deviceCode: string
userCode?: string
verificationUri?: string
verificationUriComplete: string
expiresInSeconds: number
intervalSeconds: number
qrDataUrl?: string
}

export type FeishuSetupPoll = {
status: 'WAITING' | 'SUCCESS' | 'FAIL' | 'EXPIRED' | 'UNKNOWN'
domain?: string
openId?: string
failReason?: string
config?: AdapterFileConfig
}

export const adaptersApi = {
getConfig() {
return api.get<AdapterFileConfig>('/api/adapters')
Expand Down Expand Up @@ -52,4 +70,16 @@ export const adaptersApi = {
pollDingtalkRegistration(deviceCode: string) {
return api.post<DingtalkRegistrationPoll>('/api/adapters/dingtalk/registration/poll', { deviceCode })
},

beginFeishuSetup(domain = 'feishu') {
return api.post<FeishuSetupBegin>('/api/adapters/feishu/setup/begin', { domain })
},

pollFeishuSetup(deviceCode: string, domain = 'feishu') {
return api.post<FeishuSetupPoll>('/api/adapters/feishu/setup/poll', { deviceCode, domain })
},

unbindFeishu() {
return api.post<AdapterFileConfig>('/api/adapters/feishu/unbind', {})
},
}
146 changes: 146 additions & 0 deletions desktop/src/pages/AdapterSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ export function AdapterSettings() {
pollDingtalkRegistration,
unbindWechatAccount,
unbindDingtalkBot,
beginFeishuSetup,
pollFeishuSetup,
unbindFeishu,
} = useAdapterStore()

// Active IM tab —— Feishu 默认展示,在前
Expand All @@ -45,6 +48,18 @@ export function AdapterSettings() {
const [fsVerificationToken, setFsVerificationToken] = useState('')
const [fsAllowedUsers, setFsAllowedUsers] = useState('')
const [fsStreamingCard, setFsStreamingCard] = useState(false)
// Feishu QR scan-to-create
const [fsRegistration, setFsRegistration] = useState<{
deviceCode: string
verificationUriComplete: string
qrDataUrl?: string
intervalSeconds: number
expiresAt: number
} | null>(null)
const [fsAuthStatus, setFsAuthStatus] = useState<'idle' | 'waiting' | 'bound' | 'error'>('idle')
const [fsAuthError, setFsAuthError] = useState('')
const [isStartingFsAuth, setIsStartingFsAuth] = useState(false)
const [isUnbindingFs, setIsUnbindingFs] = useState(false)

// WeChat
const [wcAllowedUsers, setWcAllowedUsers] = useState('')
Expand Down Expand Up @@ -189,6 +204,48 @@ export function AdapterSettings() {
}
}, [dtRegistration, dtAuthStatus, pollDingtalkRegistration, fetchConfig, t])

// Feishu QR poll
useEffect(() => {
if (!fsRegistration || fsAuthStatus !== 'waiting') return

let cancelled = false
const poll = async () => {
if (Date.now() > fsRegistration.expiresAt) {
setFsAuthStatus('error')
setFsAuthError(t('settings.adapters.dingtalkAuthExpired'))
setFsRegistration(null)
return
}

try {
const result = await pollFeishuSetup(fsRegistration.deviceCode)
if (cancelled) return
if (result.status === 'SUCCESS') {
setFsAuthStatus('bound')
setFsRegistration(null)
setFsAuthError('')
await fetchConfig()
} else if (result.status === 'FAIL' || result.status === 'EXPIRED') {
setFsAuthStatus('error')
setFsAuthError(result.failReason || t('settings.adapters.dingtalkAuthFailed'))
setFsRegistration(null)
}
} catch (err) {
if (!cancelled) {
setFsAuthStatus('error')
setFsAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed'))
}
}
}

const timer = window.setInterval(poll, Math.max(1, fsRegistration.intervalSeconds) * 1000)
void poll()
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [fsRegistration, fsAuthStatus, pollFeishuSetup, fetchConfig, t])

async function handleSave() {
setIsSaving(true)
setSaveStatus('idle')
Expand Down Expand Up @@ -347,6 +404,44 @@ export function AdapterSettings() {
}
}, [unbindDingtalkBot, fetchConfig, t])

const handleStartFeishuAuth = useCallback(async () => {
setIsStartingFsAuth(true)
setFsAuthStatus('idle')
setFsAuthError('')
try {
const begin = await beginFeishuSetup()
setFsRegistration({
deviceCode: begin.deviceCode,
verificationUriComplete: begin.verificationUriComplete,
qrDataUrl: begin.qrDataUrl,
intervalSeconds: begin.intervalSeconds,
expiresAt: Date.now() + begin.expiresInSeconds * 1000,
})
setFsAuthStatus('waiting')
} catch (err) {
setFsAuthStatus('error')
setFsAuthError(err instanceof Error ? err.message : t('settings.adapters.dingtalkAuthFailed'))
} finally {
setIsStartingFsAuth(false)
}
}, [beginFeishuSetup, t])

const handleUnbindFeishuBot = useCallback(async () => {
setIsUnbindingFs(true)
setFsAuthError('')
try {
await unbindFeishu()
setFsAuthStatus('idle')
setFsRegistration(null)
await fetchConfig()
} catch (err) {
setFsAuthStatus('error')
setFsAuthError(err instanceof Error ? err.message : 'Failed to unbind Feishu')
} finally {
setIsUnbindingFs(false)
}
}, [unbindFeishu, fetchConfig])

const handleUnbind = useCallback(async (platform: ImPlatform, userId: string | number) => {
setPendingUnbind({ platform, userId })
}, [])
Expand Down Expand Up @@ -505,6 +600,57 @@ export function AdapterSettings() {

{activeIm === 'feishu' && (
<div className="p-4 space-y-4">
<div className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<h4 className="text-sm font-semibold text-[var(--color-text-primary)]">
{config.feishu?.appId ? t('settings.adapters.dingtalkBound') : t('settings.adapters.dingtalkQrTitle')}
</h4>
<p className="mt-1 text-xs text-[var(--color-text-tertiary)]">{t('settings.adapters.dingtalkQrDesc')}</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button onClick={handleStartFeishuAuth} loading={isStartingFsAuth} size="sm">
{t('settings.adapters.dingtalkStartAuth')}
</Button>
{(config.feishu?.appId || fsAppId) && (
<Button onClick={handleUnbindFeishuBot} loading={isUnbindingFs} size="sm" variant="danger">
{t('settings.adapters.dingtalkUnbindBot')}
</Button>
)}
</div>
</div>

{fsRegistration && (
<div className="flex flex-wrap items-center gap-4">
{fsRegistration.qrDataUrl ? (
<img
src={fsRegistration.qrDataUrl}
alt={t('settings.adapters.dingtalkQrAlt')}
className="h-40 w-40 rounded-lg border border-[var(--color-border)] bg-white object-contain p-2"
/>
) : null}
<div className="min-w-0 flex-1 space-y-2">
<p className="text-sm text-[var(--color-text-primary)]">{t('settings.adapters.dingtalkWaiting')}</p>
<a
href={fsRegistration.verificationUriComplete}
target="_blank"
rel="noreferrer"
className="block truncate text-xs text-[var(--color-brand)] hover:underline"
>
{fsRegistration.verificationUriComplete}
</a>
</div>
</div>
)}

{fsAuthStatus === 'bound' && (
<p className="text-sm text-[var(--color-success)]">{t('settings.adapters.dingtalkBound')}</p>
)}
{fsAuthStatus === 'error' && (
<p className="text-sm text-[var(--color-error)]">{fsAuthError}</p>
)}
</div>

<div className="grid grid-cols-2 gap-4">
<Input
label={t('settings.adapters.appId')}
Expand Down
22 changes: 21 additions & 1 deletion desktop/src/stores/adapterStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { create } from 'zustand'
import { adaptersApi } from '../api/adapters'
import type { AdapterFileConfig } from '../types/adapter'
import type { DingtalkRegistrationBegin, DingtalkRegistrationPoll } from '../api/adapters'
import type { DingtalkRegistrationBegin, DingtalkRegistrationPoll, FeishuSetupBegin, FeishuSetupPoll } from '../api/adapters'

/**
* Tauri command 触发器:让主进程 kill + respawn adapter sidecar,
Expand Down Expand Up @@ -56,6 +56,9 @@ type AdapterStore = {
pollDingtalkRegistration: (deviceCode: string) => Promise<DingtalkRegistrationPoll>
unbindWechatAccount: () => Promise<void>
unbindDingtalkBot: () => Promise<void>
beginFeishuSetup: () => Promise<FeishuSetupBegin>
pollFeishuSetup: (deviceCode: string) => Promise<FeishuSetupPoll>
unbindFeishu: () => Promise<void>
}

export const useAdapterStore = create<AdapterStore>((set, get) => ({
Expand Down Expand Up @@ -137,6 +140,23 @@ export const useAdapterStore = create<AdapterStore>((set, get) => ({
void notifyTauriRestartAdapters()
},

beginFeishuSetup: () => adaptersApi.beginFeishuSetup(),

pollFeishuSetup: async (deviceCode) => {
const result = await adaptersApi.pollFeishuSetup(deviceCode)
if (result.config) {
set({ config: result.config })
void notifyTauriRestartAdapters()
}
return result
},

unbindFeishu: async () => {
const config = await adaptersApi.unbindFeishu()
set({ config })
void notifyTauriRestartAdapters()
},

removePairedUser: async (platform, userId) => {
const { config } = get()
const platformConfig = config[platform]
Expand Down
1 change: 1 addition & 0 deletions desktop/src/types/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export type AdapterFileConfig = {
feishu?: {
appId?: string
appSecret?: string
domain?: string
encryptKey?: string
verificationToken?: string
allowedUsers?: string[]
Expand Down
73 changes: 73 additions & 0 deletions src/server/__tests__/adapters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,4 +148,77 @@ describe('Adapters API', () => {
expect(json.dingtalk.permissionCardTemplateId).toBeUndefined()
expect(json.dingtalk.pairedUsers).toEqual([])
})

it('clears Feishu credentials on unbind', async () => {
const put = makeRequest('PUT', '/api/adapters', {
feishu: {
appId: 'cli_test',
appSecret: 'secret_test',
domain: 'feishu',
encryptKey: 'enc_test',
verificationToken: 'tok_test',
allowedUsers: ['ou_allowed'],
streamingCard: true,
pairedUsers: [{ userId: 'ou_user', displayName: 'Feishu User', pairedAt: 1 }],
},
})
await handleAdaptersApi(put.req, put.url, put.segments)

const unbind = makeRequest('POST', '/api/adapters/feishu/unbind')
const res = await handleAdaptersApi(unbind.req, unbind.url, unbind.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.feishu.appId).toBeUndefined()
expect(json.feishu.appSecret).toBeUndefined()
expect(json.feishu.domain).toBeUndefined()
expect(json.feishu.encryptKey).toBeUndefined()
expect(json.feishu.verificationToken).toBeUndefined()
expect(json.feishu.allowedUsers).toEqual([])
expect(json.feishu.pairedUsers).toEqual([])
expect(json.feishu.streamingCard).toBe(false)
})

it('begins Feishu QR registration and returns QR payload', async () => {
// Mock the Feishu accounts endpoint
const mockInit = {
nonce: 'test_nonce',
supported_auth_methods: ['client_secret'],
}
const mockBegin = {
device_code: 'dc_test_feishu',
verification_uri_complete: 'https://accounts.feishu.cn/scan/dc_test',
user_code: 'XYZ-123',
interval: 3,
expire_in: 600,
}
let callCount = 0
const originalFetch = globalThis.fetch
globalThis.fetch = async (input: any, init?: any) => {
callCount++
const url = typeof input === 'string' ? input : input?.url || ''
if (url.includes('accounts.feishu.cn')) {
const body = init?.body as string || ''
if (body.includes('action=init')) {
return new Response(JSON.stringify(mockInit), { status: 200 })
}
if (body.includes('action=begin')) {
return new Response(JSON.stringify(mockBegin), { status: 200 })
}
}
return originalFetch(input, init)
}

try {
const begin = makeRequest('POST', '/api/adapters/feishu/setup/begin', { domain: 'feishu' })
const res = await handleAdaptersApi(begin.req, begin.url, begin.segments)
expect(res.status).toBe(200)
const json = await res.json() as any
expect(json.deviceCode).toBe('dc_test_feishu')
expect(json.verificationUriComplete).toContain('accounts.feishu.cn')
expect(json.expiresInSeconds).toBe(600)
expect(callCount).toBe(2) // init + begin
} finally {
globalThis.fetch = originalFetch
}
})
})
Loading
Loading