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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ packages/web/public/worker-*.js
.kiro/
.trae/
.dare/
.claude/
.codex/
.gemini/
*.swp
Expand Down
63 changes: 63 additions & 0 deletions docs/local-patches.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
doc_kind: tracking
created: 2026-03-30
---

# Local Patches (upstream divergence tracking)

Tracks commits that are **local customizations** not intended for upstream.
During `sync` from upstream, these need to be rebased or re-applied.

## Active Patches

### Feishu QR Code Bind Flow

> **Status**: active | **Since**: 2026-03-29 | **Branch**: feat/feishu-qr-bind

飞书扫码绑定 connector 的完整流程,Clowder 特有业务功能。

**Commits** (oldest first):
| Commit | Description |
|--------|-------------|
| `d85f0fb` | feat(connector): support feishu qr bind flow in hub |
| `2beb523` | fix(ci): format HubConnectorConfigTab for biome |
| `321e8c7` | fix(review): tighten connector save hint and stabilize feishu qr polling |
| `09391f9` | fix(test): isolate env vars in Feishu QR credential-persist test |

**Files touched** (conflict risk during sync):
- `packages/api/src/routes/connector-hub.ts` — 215 lines added (new routes)
- `packages/web/src/components/HubConnectorConfigTab.tsx` — import + render changes
- `packages/web/src/components/FeishuQrPanel.tsx` — new file
- `packages/api/test/connector-hub-route.test.js` — new test
- `packages/web/src/components/__tests__/feishu-qr-panel.test.tsx` — new test
- `packages/web/src/components/__tests__/hub-connector-config-tab.test.tsx` — new test

### Connector Admin Hint

> **Status**: PR'd upstream (zts212653/clowder-ai#308) | **Since**: 2026-03-30

管理员权限报错时显示 open_id hint。

**Commits**:
| Commit | Description |
|--------|-------------|
| `80824d6` | fix(connector): show open_id hint when non-admin uses permission commands |

**Files touched**:
- `packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts`

**Action**: upstream 合并后,下次 sync 自动消除。可在 sync 后从此文档移除。

## Resolved Patches

_None yet._

---

## Sync Checklist

每次从上游 sync 后:
1. `git rebase` active patches onto new sync commit
2. 解决冲突时参照上面的 files-touched 列表
3. 检查 PR'd 的 patch 是否已被上游合并 → 移到 Resolved
4. 跑 `pnpm check && pnpm lint && pnpm test` 确认无回归
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,10 @@ export class ConnectorCommandLayer {
chatIdArg?: string,
): Promise<CommandResult> {
if (!(await this.isAdminSender(connectorId, senderId))) {
return { kind: 'allow-group', response: '🔒 此命令仅管理员可用。' };
const hint = senderId
? `你的 open_id: ${senderId},请在 Hub 权限管理中添加此 ID。`
: '无法识别发送者(请从群聊中发送此命令)。';
return { kind: 'allow-group', response: `🔒 此命令仅管理员可用。${hint}` };
}
const store = this.deps.permissionStore;
if (!store) {
Expand All @@ -333,7 +336,10 @@ export class ConnectorCommandLayer {
chatIdArg?: string,
): Promise<CommandResult> {
if (!(await this.isAdminSender(connectorId, senderId))) {
return { kind: 'deny-group', response: '🔒 此命令仅管理员可用。' };
const hint = senderId
? `你的 open_id: ${senderId},请在 Hub 权限管理中添加此 ID。`
: '无法识别发送者(请从群聊中发送此命令)。';
return { kind: 'deny-group', response: `🔒 此命令仅管理员可用。${hint}` };
}
const store = this.deps.permissionStore;
if (!store) {
Expand Down
215 changes: 215 additions & 0 deletions packages/api/src/routes/connector-hub.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify';
import { DEFAULT_THREAD_ID, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js';
import type { WeixinAdapter } from '../infrastructure/connectors/adapters/WeixinAdapter.js';
import type { IConnectorPermissionStore } from '../infrastructure/connectors/ConnectorPermissionStore.js';
import { resolveActiveProjectRoot } from '../utils/active-project-root.js';
import { resolveHeaderUserId } from '../utils/request-identity.js';

export interface ConnectorHubRoutesOptions {
Expand All @@ -16,6 +19,10 @@ export interface ConnectorHubRoutesOptions {
startWeixinPolling?: () => void;
/** F134 Phase D: Permission store for group whitelist + admin management */
permissionStore?: IConnectorPermissionStore | null;
/** Optional override for writing connector env updates in tests */
envFilePath?: string;
/** Optional fetch override for Feishu registration API in tests */
feishuRegistrationFetch?: typeof fetch;
}

function requireTrustedHubIdentity(request: FastifyRequest, reply: FastifyReply): string | null {
Expand Down Expand Up @@ -57,6 +64,11 @@ interface PlatformDef {
steps: PlatformStepDef[];
}

const FEISHU_ACCOUNTS_BASE_URL = 'https://accounts.feishu.cn';
const LARK_ACCOUNTS_BASE_URL = 'https://accounts.larksuite.com';

type FeishuRegistrationResponse = Record<string, unknown>;

export const CONNECTOR_PLATFORMS: PlatformDef[] = [
{
id: 'feishu',
Expand Down Expand Up @@ -169,6 +181,86 @@ function maskSensitiveValue(_value: string): string {
return '••••••••';
}

function formatEnvFileValue(value: string): string {
const escapedControlChars = value.replace(/\r/g, '\\r').replace(/\n/g, '\\n');
if (/^[A-Za-z0-9_./:@-]+$/.test(escapedControlChars)) return escapedControlChars;
return `"${escapedControlChars
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\$/g, '\\$')
.replace(/`/g, '\\`')}"`;
}

function applyEnvUpdatesToFile(contents: string, updates: Map<string, string | null>): string {
const lines = contents === '' ? [] : contents.split(/\r?\n/);
const seen = new Set<string>();
const nextLines: string[] = [];

for (const line of lines) {
const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
if (!match) {
nextLines.push(line);
continue;
}
const name = match[1]!;
if (!updates.has(name)) {
nextLines.push(line);
continue;
}
seen.add(name);
const value = updates.get(name);
if (value == null || value === '') continue;
nextLines.push(`${name}=${formatEnvFileValue(value)}`);
}

for (const [name, value] of updates) {
if (seen.has(name) || value == null || value === '') continue;
nextLines.push(`${name}=${formatEnvFileValue(value)}`);
}

const normalized = nextLines
.join('\n')
.replace(/\n{3,}/g, '\n\n')
.trimEnd();
return normalized.length > 0 ? `${normalized}\n` : '';
}

function persistEnvUpdates(envFilePath: string, updates: Map<string, string | null>): void {
const current = existsSync(envFilePath) ? readFileSync(envFilePath, 'utf8') : '';
const next = applyEnvUpdatesToFile(current, updates);
writeFileSync(envFilePath, next, 'utf8');
for (const [name, value] of updates) {
if (value == null || value === '') delete process.env[name];
else process.env[name] = value;
}
}

async function postFeishuRegistration(
fetchFn: typeof fetch,
baseUrl: string,
form: URLSearchParams,
): Promise<FeishuRegistrationResponse> {
const res = await fetchFn(`${baseUrl}/oauth/v1/app/registration`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: form.toString(),
});
const data = (await res.json().catch(() => ({}))) as FeishuRegistrationResponse;
if (!res.ok && !('error' in data)) {
throw new Error(`registration api ${res.status}`);
}
return data;
}

function toPositiveNumber(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value;
if (typeof value === 'string') {
const parsed = Number(value);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return fallback;
}

export interface PlatformFieldStatus {
envName: string;
label: string;
Expand Down Expand Up @@ -237,6 +329,8 @@ export function buildConnectorStatus(env: Record<string, string | undefined> = p

export const connectorHubRoutes: FastifyPluginAsync<ConnectorHubRoutesOptions> = async (app, opts) => {
const { threadStore } = opts;
const envFilePath = opts.envFilePath ?? resolve(resolveActiveProjectRoot(), '.env');
const feishuRegistrationFetch = opts.feishuRegistrationFetch ?? globalThis.fetch;

app.get('/api/connector/hub-threads', async (request, reply) => {
const userId = requireTrustedHubIdentity(request, reply);
Expand Down Expand Up @@ -274,6 +368,127 @@ export const connectorHubRoutes: FastifyPluginAsync<ConnectorHubRoutesOptions> =
return { platforms: status };
});

// ── Feishu QR code create/bind routes ──

app.post('/api/connector/feishu/qrcode', async (request, reply) => {
const userId = requireTrustedHubIdentity(request, reply);
if (!userId) return { error: 'Identity required' };

try {
const initData = await postFeishuRegistration(
feishuRegistrationFetch,
FEISHU_ACCOUNTS_BASE_URL,
new URLSearchParams({ action: 'init' }),
);
const supportedMethods = Array.isArray(initData.supported_auth_methods) ? initData.supported_auth_methods : [];
if (!supportedMethods.includes('client_secret')) {
reply.status(502);
return { error: 'Feishu registration endpoint does not support client_secret auth method' };
}

const beginData = await postFeishuRegistration(
feishuRegistrationFetch,
FEISHU_ACCOUNTS_BASE_URL,
new URLSearchParams({
action: 'begin',
archetype: 'PersonalAgent',
auth_method: 'client_secret',
request_user_info: 'open_id',
}),
);

const verificationUri = beginData.verification_uri_complete;
const deviceCode = beginData.device_code;
if (typeof verificationUri !== 'string' || typeof deviceCode !== 'string') {
reply.status(502);
return { error: 'Feishu registration response is missing QR payload' };
}

const qrUrl = new URL(verificationUri);
qrUrl.searchParams.set('from', 'onboard');

const QRCode = await import('qrcode');
const qrDataUri = await QRCode.toDataURL(qrUrl.toString(), { width: 384, margin: 2 });

return {
qrUrl: qrDataUri,
qrPayload: deviceCode,
interval: toPositiveNumber(beginData.interval, 5),
expiresIn: toPositiveNumber(beginData.expire_in, 600),
};
} catch (err) {
app.log.error({ err }, '[Feishu QR] Failed to fetch QR code');
reply.status(502);
return { error: 'Failed to fetch QR code from Feishu registration service' };
}
});

app.get('/api/connector/feishu/qrcode-status', async (request, reply) => {
const userId = requireTrustedHubIdentity(request, reply);
if (!userId) return { error: 'Identity required' };

const { qrPayload } = request.query as { qrPayload?: string };
if (!qrPayload) {
reply.status(400);
return { error: 'qrPayload query parameter required' };
}

try {
const pollForm = new URLSearchParams({ action: 'poll', device_code: qrPayload });
let pollData = await postFeishuRegistration(feishuRegistrationFetch, FEISHU_ACCOUNTS_BASE_URL, pollForm);

const tenantBrand = ((pollData.user_info as Record<string, unknown> | undefined)?.tenant_brand ?? '') as string;
const hasCredentials = typeof pollData.client_id === 'string' && typeof pollData.client_secret === 'string';
if (!hasCredentials && tenantBrand === 'lark') {
try {
pollData = await postFeishuRegistration(feishuRegistrationFetch, LARK_ACCOUNTS_BASE_URL, pollForm);
} catch (err) {
app.log.warn({ err }, '[Feishu QR] Lark poll fallback failed');
}
}

const clientId = pollData.client_id;
const clientSecret = pollData.client_secret;
if (typeof clientId === 'string' && typeof clientSecret === 'string') {
const updates = new Map<string, string | null>([
['FEISHU_APP_ID', clientId],
['FEISHU_APP_SECRET', clientSecret],
]);
const currentMode = process.env.FEISHU_CONNECTION_MODE === 'websocket' ? 'websocket' : 'webhook';
const verificationToken = process.env.FEISHU_VERIFICATION_TOKEN;
if (currentMode === 'webhook' && (!verificationToken || verificationToken.trim() === '')) {
// QR onboarding does not return webhook verification token; default to websocket so setup is immediately valid.
updates.set('FEISHU_CONNECTION_MODE', 'websocket');
}
persistEnvUpdates(envFilePath, updates);
app.log.info('[Feishu QR] Bot credentials captured and persisted to env file');
return { status: 'confirmed' };
}

const errorCode = pollData.error;
if (errorCode === 'authorization_pending' || errorCode === 'slow_down') {
return { status: 'waiting' };
}
if (errorCode === 'access_denied') {
return { status: 'denied' };
}
if (errorCode === 'expired_token') {
return { status: 'expired' };
}
if (typeof errorCode === 'string') {
return {
status: 'error',
error: typeof pollData.error_description === 'string' ? pollData.error_description : errorCode,
};
}
return { status: 'waiting' };
} catch (err) {
app.log.error({ err }, '[Feishu QR] Failed to poll QR status');
reply.status(502);
return { error: 'Failed to poll Feishu QR status' };
}
});

// ── F137: WeChat QR code login routes ──

app.post('/api/connector/weixin/qrcode', async (request, reply) => {
Expand Down
Loading