x'), '**bold**\n`x`');
+ });
+
+ it('builds markdown message payloads', () => {
+ assert.deepEqual(buildMarkdownMessage('hello'), {
+ msgtype: 'markdown',
+ markdown: { content: 'hello' },
+ });
+ });
+});
+
+describe('WeCom permission helpers', () => {
+ const buttons = [[
+ { text: 'Allow once', callbackData: 'perm:allow:req-1' },
+ { text: 'Deny', callbackData: 'perm:deny:req-1' },
+ ]];
+
+ it('renders /perm fallback text', () => {
+ const text = buildPermissionCommandText('Need approval', buttons);
+ assert.ok(text.includes('/perm allow req-1'));
+ assert.ok(text.includes('/perm deny req-1'));
+ });
+
+ it('builds clickable permission cards', () => {
+ const card = buildPermissionCard(buttons, 'perm_fixed');
+ assert.equal(card.msgtype, 'template_card');
+ assert.equal(card.template_card.card_type, 'button_interaction');
+ assert.equal(card.template_card.task_id, 'perm_fixed');
+ assert.equal(card.template_card.button_list?.[0]?.key, 'perm:allow:req-1');
+ });
+});
\ No newline at end of file
diff --git a/src/app/api/bridge/settings/route.ts b/src/app/api/bridge/settings/route.ts
index 4787e210..e0d003f5 100644
--- a/src/app/api/bridge/settings/route.ts
+++ b/src/app/api/bridge/settings/route.ts
@@ -22,6 +22,12 @@ const BRIDGE_SETTING_KEYS = [
'bridge_feishu_group_policy',
'bridge_feishu_group_allow_from',
'bridge_feishu_require_mention',
+ 'bridge_wecom_enabled',
+ 'bridge_wecom_bot_id',
+ 'bridge_wecom_secret',
+ 'bridge_wecom_allowed_users',
+ 'bridge_wecom_group_policy',
+ 'bridge_wecom_group_allow_from',
'bridge_discord_enabled',
'bridge_discord_bot_token',
'bridge_discord_allowed_users',
diff --git a/src/app/api/settings/wecom/route.ts b/src/app/api/settings/wecom/route.ts
new file mode 100644
index 00000000..b2ac1871
--- /dev/null
+++ b/src/app/api/settings/wecom/route.ts
@@ -0,0 +1,59 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { getSetting, setSetting } from '@/lib/db';
+
+const WECOM_KEYS = [
+ 'bridge_wecom_enabled',
+ 'bridge_wecom_bot_id',
+ 'bridge_wecom_secret',
+ 'bridge_wecom_allowed_users',
+ 'bridge_wecom_group_policy',
+ 'bridge_wecom_group_allow_from',
+] as const;
+
+export async function GET() {
+ try {
+ const result: Record{t("bridge.wecomChannel")}
++ {t("bridge.wecomChannelDesc")} +
+{t("bridge.discordChannel")}
{t("bridge.discordChannelDesc")}
@@ -358,11 +384,10 @@ export function BridgeSection() { {adapter.channelType}+ {t("wecom.credentialsDesc")} +
++ {t("wecom.allowedUsersDesc")} +
++ {t("wecom.allowedUsersHint")} +
++ {t("wecom.groupSettingsDesc")} +
++ {t("wecom.groupAllowFromHint")} +
+(.*?)<\/code>/gi, '`$1`')
+ .replace(/([\s\S]*?)<\/pre>/gi, '```\n$1\n```')
+ .replace(/
/gi, '\n')
+ .replace(/<\/p>/gi, '\n')
+ .replace(/<[^>]+>/g, '')
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/\n{3,}/g, '\n\n')
+ .trim();
+}
+
+export function buildMarkdownMessage(text: string): SendMarkdownMsgBody {
+ return {
+ msgtype: 'markdown',
+ markdown: { content: text },
+ };
+}
+
+export function buildPermissionCommands(inlineButtons: InlineButton[][]): string[] {
+ return inlineButtons.flat().map((btn) => {
+ if (!btn.callbackData.startsWith('perm:')) return btn.text;
+
+ const parts = btn.callbackData.split(':');
+ const action = parts[1];
+ const permId = parts.slice(2).join(':');
+ return `/perm ${action} ${permId}`;
+ });
+}
+
+export function buildPermissionCommandText(
+ text: string,
+ inlineButtons: InlineButton[][],
+): string {
+ const sections = [text.trim(), 'Reply with one of these commands:', ...buildPermissionCommands(inlineButtons)]
+ .filter(Boolean);
+ return sections.join('\n\n');
+}
+
+export function buildPermissionCard(
+ inlineButtons: InlineButton[][],
+ taskId = `perm_${Date.now()}`,
+ title = '需要操作权限',
+ desc = '请选择一个审批动作'
+): SendTemplateCardMsgBody {
+ return {
+ msgtype: 'template_card',
+ template_card: {
+ card_type: TemplateCardType.ButtonInteraction,
+ main_title: {
+ title,
+ desc,
+ },
+ sub_title_text: '请通过点击下方按钮批准或拒绝来自 WeCom Bridge 的请求。',
+ button_list: inlineButtons
+ .flat()
+ .slice(0, 6)
+ .map((btn) => ({
+ text: btn.text.slice(0, 24),
+ key: btn.callbackData,
+ })),
+ task_id: taskId,
+ },
+ };
+}
\ No newline at end of file
diff --git a/src/lib/bridge/types.ts b/src/lib/bridge/types.ts
index 69b780f1..fabfd6e2 100644
--- a/src/lib/bridge/types.ts
+++ b/src/lib/bridge/types.ts
@@ -172,5 +172,6 @@ export const PLATFORM_LIMITS: Record = {
discord: 2000,
slack: 40000,
feishu: 30000,
+ wecom: 20000,
qq: 2000,
};
diff --git a/src/lib/claude-client.ts b/src/lib/claude-client.ts
index 6de134fd..d7b8e8bc 100644
--- a/src/lib/claude-client.ts
+++ b/src/lib/claude-client.ts
@@ -394,6 +394,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream