@wu529778790/open-im 1.0.2-beta.3 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -87,8 +87,16 @@ open-im run
87
87
 
88
88
  1. 在 [飞书开放平台](https://open.feishu.cn/) 创建企业自建应用
89
89
  2. 开启「机器人」能力
90
- 3. 配置事件订阅:启用 `im.message.receive_v1`,使用 **长连接** 模式(WebSocket)
91
- 4. 将机器人添加到目标群聊或发起私聊
90
+ 3. 配置事件订阅:启用 `im.message.receive_v1`(接收消息),使用 **长连接** 模式(WebSocket)
91
+ 4. **卡片按钮(/mode、权限允许/拒绝)需额外配置回调**:
92
+ - 打开 [开放平台](https://open.feishu.cn/app) → 进入你的应用 → **「事件与回调」**
93
+ - 注意:页面有 **「事件」** 和 **「回调」** 两个 Tab,卡片在 **「回调」** 里,不在「事件」里
94
+ - 切换到 **「回调」** Tab → 选择 **「使用长连接接收回调」**
95
+ - 点击 **「添加回调」**(或类似按钮)→ 在列表中找到 **「卡片回传交互」**(`card.action.trigger`)
96
+ - 若列表里搜不到,可尝试:切换分类、搜「action」或「trigger」、或直接浏览「消息与群组」相关分类
97
+ 5. 将机器人添加到目标群聊或发起私聊
98
+
99
+ **若点击 /mode 卡片按钮报错**:说明未配置卡片回调。配置较复杂时,可直接用 `/mode ask`、`/mode yolo` 等命令切换模式,无需卡片。
92
100
 
93
101
  ## 开发
94
102
 
@@ -103,6 +111,8 @@ npm run foreground # 前台运行已构建版本
103
111
  | 命令 | 说明 |
104
112
  |------|------|
105
113
  | `/help` | 显示帮助 |
114
+ | `/mode` | 切换权限模式(卡片/按钮选择) |
115
+ | `/mode <模式>` | 直接切换:ask / accept-edits / plan / yolo |
106
116
  | `/new` | 开始新会话 |
107
117
  | `/status` | 显示状态(AI 工具、工作目录、费用等) |
108
118
  | `/cd <路径>` | 切换工作目录 |
@@ -110,6 +120,17 @@ npm run foreground # 前台运行已构建版本
110
120
  | `/allow` `/y` | 允许权限请求 |
111
121
  | `/deny` `/n` | 拒绝权限请求 |
112
122
 
123
+ ### 权限模式
124
+
125
+ 与 Claude Code 官方命名一致,见 [permissions](https://code.claude.com/docs/en/permissions):
126
+
127
+ | 模式 | Claude 名 | 说明 |
128
+ |------|-----------|------|
129
+ | ask | default | 首次使用每个工具时提示确认 |
130
+ | accept-edits | acceptEdits | 编辑权限自动通过 |
131
+ | plan | plan | 仅分析,不修改文件不执行命令 |
132
+ | yolo | bypassPermissions | 跳过所有权限确认 |
133
+
113
134
  ## 📝 License
114
135
 
115
136
  [MIT](LICENSE)
@@ -214,3 +235,14 @@ npx @wu529778790/open-im run
214
235
  3. **用户 ID 白名单问题**
215
236
  - 检查配置文件中的 `allowedUserIds` 是否包含你的用户 ID
216
237
  - 或留空允许所有人访问(仅开发环境建议)
238
+
239
+ ### Q: 飞书 /mode 卡片点击报错(如 200340)?
240
+
241
+ 说明未配置**卡片回调**。注意:卡片在 **「回调」** Tab,不在「事件」Tab。
242
+
243
+ 1. 打开 [飞书开放平台](https://open.feishu.cn/app) → 进入你的应用 → **「事件与回调」**
244
+ 2. 切换到 **「回调」** Tab(不是「事件」)
245
+ 3. 选择 **「使用长连接接收回调」**
246
+ 4. 添加 **「卡片回传交互」**(`card.action.trigger`)— 若搜不到,可尝试搜「action」「trigger」或浏览分类
247
+
248
+ **更简单**:直接用 `/mode ask`、`/mode yolo` 等命令切换模式,无需配置卡片。
@@ -17,6 +17,7 @@ export class ClaudeAdapter {
17
17
  run(prompt, sessionId, workDir, callbacks, options) {
18
18
  const opts = {
19
19
  skipPermissions: options?.skipPermissions,
20
+ permissionMode: options?.permissionMode,
20
21
  timeoutMs: options?.timeoutMs,
21
22
  model: options?.model,
22
23
  chatId: options?.chatId,
@@ -21,6 +21,8 @@ export interface RunCallbacks {
21
21
  }
22
22
  export interface RunOptions {
23
23
  skipPermissions?: boolean;
24
+ /** Claude --permission-mode: default | acceptEdits | plan(yolo 时用 skipPermissions) */
25
+ permissionMode?: 'default' | 'acceptEdits' | 'plan';
24
26
  timeoutMs?: number;
25
27
  model?: string;
26
28
  chatId?: string;
@@ -17,6 +17,7 @@ export interface ClaudeRunCallbacks {
17
17
  }
18
18
  export interface ClaudeRunOptions {
19
19
  skipPermissions?: boolean;
20
+ permissionMode?: 'default' | 'acceptEdits' | 'plan';
20
21
  timeoutMs?: number;
21
22
  model?: string;
22
23
  chatId?: string;
@@ -12,8 +12,12 @@ export function runClaude(cliPath, prompt, sessionId, workDir, callbacks, option
12
12
  "--verbose",
13
13
  "--include-partial-messages",
14
14
  ];
15
- if (options?.skipPermissions)
15
+ if (options?.skipPermissions) {
16
16
  args.push("--dangerously-skip-permissions");
17
+ }
18
+ else if (options?.permissionMode) {
19
+ args.push("--permission-mode", options.permissionMode);
20
+ }
17
21
  if (options?.model)
18
22
  args.push("--model", options.model);
19
23
  if (sessionId)
@@ -27,6 +27,7 @@ export interface ClaudeResult {
27
27
  }
28
28
  export interface ClaudeRunOptions {
29
29
  skipPermissions?: boolean;
30
+ permissionMode?: 'default' | 'acceptEdits' | 'plan';
30
31
  timeoutMs?: number;
31
32
  model?: string;
32
33
  chatId?: string;
@@ -62,8 +62,12 @@ export class ClaudeProcessPool {
62
62
  "--verbose",
63
63
  "--include-partial-messages",
64
64
  ];
65
- if (options.skipPermissions)
65
+ if (options.skipPermissions) {
66
66
  args.push("--dangerously-skip-permissions");
67
+ }
68
+ else if (options.permissionMode) {
69
+ args.push("--permission-mode", options.permissionMode);
70
+ }
67
71
  if (options.model)
68
72
  args.push("--model", options.model);
69
73
  if (sessionId)
@@ -1,11 +1,14 @@
1
1
  import type { Config } from '../config.js';
2
2
  import type { SessionManager } from '../session/session-manager.js';
3
3
  import type { RequestQueue } from '../queue/request-queue.js';
4
+ import { type PermissionMode } from '../permission-mode/types.js';
4
5
  import type { ThreadContext } from '../shared/types.js';
5
6
  export type { ThreadContext };
6
7
  export interface MessageSender {
7
8
  sendTextReply(chatId: string, text: string, threadCtx?: ThreadContext): Promise<void>;
8
9
  sendDirectorySelection?(chatId: string, currentDir: string, userId: string): Promise<void>;
10
+ sendModeCard?(chatId: string, userId: string, currentMode: PermissionMode): Promise<void>;
11
+ sendModeKeyboard?(chatId: string, userId: string, currentMode: PermissionMode): Promise<void>;
9
12
  }
10
13
  export interface CommandHandlerDeps {
11
14
  config: Config;
@@ -19,6 +22,8 @@ export declare class CommandHandler {
19
22
  private deps;
20
23
  constructor(deps: CommandHandlerDeps);
21
24
  dispatch(text: string, chatId: string, userId: string, platform: 'feishu' | 'telegram', handleClaudeRequest: ClaudeRequestHandler): Promise<boolean>;
25
+ private handleMode;
26
+ private getClearHistoryHint;
22
27
  private handleHelp;
23
28
  private handleNew;
24
29
  private handlePwd;
@@ -1,4 +1,6 @@
1
1
  import { resolveLatestPermission, getPendingCount } from '../hook/permission-server.js';
2
+ import { getPermissionMode, setPermissionMode } from '../permission-mode/session-mode.js';
3
+ import { MODE_LABELS, MODE_DESCRIPTIONS, parsePermissionMode } from '../permission-mode/types.js';
2
4
  import { TERMINAL_ONLY_COMMANDS } from '../constants.js';
3
5
  import { execFile } from 'node:child_process';
4
6
  import { readdirSync } from 'node:fs';
@@ -16,8 +18,10 @@ export class CommandHandler {
16
18
  }
17
19
  if (t === '/help')
18
20
  return this.handleHelp(chatId, platform);
21
+ if (t === '/mode' || t.startsWith('/mode '))
22
+ return this.handleMode(chatId, userId, platform, t.slice(6).trim());
19
23
  if (t === '/new')
20
- return this.handleNew(chatId, userId);
24
+ return this.handleNew(chatId, userId, platform);
21
25
  if (t === '/pwd')
22
26
  return this.handlePwd(chatId, userId);
23
27
  if (t === '/status')
@@ -27,7 +31,7 @@ export class CommandHandler {
27
31
  if (t === '/deny' || t === '/n')
28
32
  return this.handleDeny(chatId);
29
33
  if (t === '/cd' || t.startsWith('/cd ')) {
30
- return this.handleCd(chatId, userId, t.slice(3).trim());
34
+ return this.handleCd(chatId, userId, t.slice(3).trim(), platform);
31
35
  }
32
36
  const cmd = t.split(/\s+/)[0];
33
37
  if (TERMINAL_ONLY_COMMANDS.has(cmd)) {
@@ -36,11 +40,46 @@ export class CommandHandler {
36
40
  }
37
41
  return false;
38
42
  }
43
+ async handleMode(chatId, userId, platform, arg) {
44
+ const defaultMode = this.deps.config.defaultPermissionMode;
45
+ const currentMode = getPermissionMode(userId, defaultMode);
46
+ if (arg) {
47
+ const parsed = parsePermissionMode(arg);
48
+ if (parsed) {
49
+ setPermissionMode(userId, parsed);
50
+ await this.deps.sender.sendTextReply(chatId, `✅ 权限模式已切换为 **${MODE_LABELS[parsed]}**\n${MODE_DESCRIPTIONS[parsed]}`);
51
+ return true;
52
+ }
53
+ await this.deps.sender.sendTextReply(chatId, `无效模式: ${arg}\n可用: ask, accept-edits, plan, yolo`);
54
+ return true;
55
+ }
56
+ if (platform === 'feishu' && this.deps.sender.sendModeCard) {
57
+ await this.deps.sender.sendModeCard(chatId, userId, currentMode);
58
+ return true;
59
+ }
60
+ if (platform === 'telegram' && this.deps.sender.sendModeKeyboard) {
61
+ await this.deps.sender.sendModeKeyboard(chatId, userId, currentMode);
62
+ return true;
63
+ }
64
+ const lines = [
65
+ `🔐 **权限模式** (当前: ${MODE_LABELS[currentMode]})`,
66
+ '',
67
+ ...['ask', 'accept-edits', 'plan', 'yolo'].map((m) => `• \`/mode ${m}\` - ${MODE_LABELS[m]}: ${MODE_DESCRIPTIONS[m]}`),
68
+ ];
69
+ await this.deps.sender.sendTextReply(chatId, lines.join('\n'));
70
+ return true;
71
+ }
72
+ getClearHistoryHint(platform) {
73
+ return platform === 'feishu'
74
+ ? '💡 提示:如需清除本对话的历史消息,请点击飞书聊天右上角「...」→ 清除聊天记录'
75
+ : '💡 提示:如需清除本对话的历史消息,请点击 Telegram 聊天右上角 ⋮ → 清除历史';
76
+ }
39
77
  async handleHelp(chatId, platform) {
40
78
  const help = [
41
79
  '📋 可用命令:',
42
80
  '',
43
81
  '/help - 显示帮助',
82
+ '/mode - 切换权限模式(安全/编辑放行/只读/YOLO)',
44
83
  '/new - 开始新会话(AI 上下文重置)',
45
84
  '/status - 显示状态',
46
85
  '/cd <路径> - 切换工作目录',
@@ -48,16 +87,15 @@ export class CommandHandler {
48
87
  '/allow (/y) - 允许权限请求',
49
88
  '/deny (/n) - 拒绝权限请求',
50
89
  '',
51
- '💡 提示:清除聊天历史请点击 Telegram 右上角 ⋮ → 清除历史',
90
+ this.getClearHistoryHint(platform),
52
91
  ].join('\n');
53
92
  await this.deps.sender.sendTextReply(chatId, help);
54
93
  return true;
55
94
  }
56
- async handleNew(chatId, userId) {
95
+ async handleNew(chatId, userId, platform) {
57
96
  const ok = this.deps.sessionManager.newSession(userId);
58
97
  await this.deps.sender.sendTextReply(chatId, ok
59
- ? '✅ AI 会话已重置,下一条消息将使用全新上下文。\n\n' +
60
- '💡 提示:如需清除本对话的历史消息,请点击 Telegram 聊天右上角 ⋮ → 清除历史'
98
+ ? `✅ AI 会话已重置,下一条消息将使用全新上下文。\n\n${this.getClearHistoryHint(platform)}`
61
99
  : '当前没有活动会话。');
62
100
  return true;
63
101
  }
@@ -82,7 +120,7 @@ export class CommandHandler {
82
120
  await this.deps.sender.sendTextReply(chatId, lines.join('\n'));
83
121
  return true;
84
122
  }
85
- async handleCd(chatId, userId, dir) {
123
+ async handleCd(chatId, userId, dir, platform) {
86
124
  // 如果 dir 为空,显示目录选择界面
87
125
  if (!dir) {
88
126
  const currentDir = this.deps.sessionManager.getWorkDir(userId);
@@ -98,7 +136,7 @@ export class CommandHandler {
98
136
  const resolved = await this.deps.sessionManager.setWorkDir(userId, dir);
99
137
  await this.deps.sender.sendTextReply(chatId, `📁 工作目录已切换到: ${resolved}\n\n` +
100
138
  `🔄 AI 会话已重置,下一条消息将使用全新上下文。\n` +
101
- `💡 提示:如需清除本对话的历史消息,请点击 Telegram 聊天右上角 ⋮ → 清除历史`);
139
+ this.getClearHistoryHint(platform));
102
140
  }
103
141
  catch (err) {
104
142
  await this.deps.sender.sendTextReply(chatId, err instanceof Error ? err.message : String(err));
package/dist/config.d.ts CHANGED
@@ -14,6 +14,7 @@ export interface Config {
14
14
  claudeWorkDir: string;
15
15
  allowedBaseDirs: string[];
16
16
  claudeSkipPermissions: boolean;
17
+ defaultPermissionMode: 'ask' | 'accept-edits' | 'plan' | 'yolo';
17
18
  claudeTimeoutMs: number;
18
19
  claudeModel?: string;
19
20
  hookPort: number;
package/dist/config.js CHANGED
@@ -84,6 +84,7 @@ export function loadConfig() {
84
84
  const claudeSkipPermissions = process.env.CLAUDE_SKIP_PERMISSIONS !== undefined
85
85
  ? process.env.CLAUDE_SKIP_PERMISSIONS === 'true'
86
86
  : file.claudeSkipPermissions ?? true;
87
+ const defaultPermissionMode = (file.defaultPermissionMode ?? 'ask');
87
88
  const claudeTimeoutMs = process.env.CLAUDE_TIMEOUT_MS !== undefined
88
89
  ? parseInt(process.env.CLAUDE_TIMEOUT_MS, 10) || 600000
89
90
  : file.claudeTimeoutMs ?? 600000;
@@ -167,6 +168,7 @@ export function loadConfig() {
167
168
  claudeWorkDir,
168
169
  allowedBaseDirs,
169
170
  claudeSkipPermissions,
171
+ defaultPermissionMode,
170
172
  claudeTimeoutMs,
171
173
  claudeModel: process.env.CLAUDE_MODEL ?? file.claudeModel,
172
174
  hookPort,
@@ -1,5 +1,5 @@
1
1
  import { Client } from '@larksuiteoapi/node-sdk';
2
2
  import type { Config } from '../config.js';
3
3
  export declare function getClient(): Client;
4
- export declare function initFeishu(config: Config, eventHandler: (data: unknown) => Promise<void>): Promise<void>;
4
+ export declare function initFeishu(config: Config, eventHandler: (data: unknown) => Promise<void | Record<string, unknown>>): Promise<void>;
5
5
  export declare function stopFeishu(): void;
@@ -34,6 +34,19 @@ export async function initFeishu(config, eventHandler) {
34
34
  log.error('[EVENT] Error calling event handler:', err);
35
35
  }
36
36
  },
37
+ // 卡片按钮点击回调(权限允许/拒绝等)
38
+ 'card.action.trigger': async (data) => {
39
+ log.info('[EVENT] Received Feishu card action event');
40
+ log.info('[EVENT] Card action data:', JSON.stringify(data).slice(0, 800));
41
+ try {
42
+ const result = await eventHandler(data);
43
+ return result;
44
+ }
45
+ catch (err) {
46
+ log.error('[EVENT] Error handling card action:', err);
47
+ return { toast: { type: 'error', content: '处理失败' } };
48
+ }
49
+ },
37
50
  });
38
51
  // Register catch-all handler using wildcard
39
52
  eventDispatcher.register({
@@ -3,6 +3,6 @@ import type { SessionManager } from '../session/session-manager.js';
3
3
  export interface FeishuEventHandlerHandle {
4
4
  stop: () => void;
5
5
  getRunningTaskCount: () => number;
6
- handleEvent: (data: unknown) => Promise<void>;
6
+ handleEvent: (data: unknown) => Promise<void | Record<string, unknown>>;
7
7
  }
8
8
  export declare function setupFeishuHandlers(config: Config, sessionManager: SessionManager): FeishuEventHandlerHandle;
@@ -2,14 +2,17 @@ import { mkdir, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { AccessControl } from '../access/access-control.js';
4
4
  import { RequestQueue } from '../queue/request-queue.js';
5
- import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, startTypingLoop, sendImageReply, createFeishuButtonCard, } from './message-sender.js';
5
+ import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, sendTextReplyByOpenId, startTypingLoop, sendImageReply, createFeishuButtonCard, sendModeCard, createFeishuModeCardReadOnly, delayUpdateCard, } from './message-sender.js';
6
6
  import { registerPermissionSender, resolvePermissionById } from '../hook/permission-server.js';
7
+ import { setPermissionMode } from '../permission-mode/session-mode.js';
8
+ import { MODE_LABELS } from '../permission-mode/types.js';
7
9
  import { CommandHandler } from '../commands/handler.js';
8
10
  import { getAdapter } from '../adapters/registry.js';
9
11
  import { runAITask } from '../shared/ai-task.js';
10
12
  import { startTaskCleanup } from '../shared/task-cleanup.js';
11
13
  import { THROTTLE_MS, IMAGE_DIR } from '../constants.js';
12
14
  import { setActiveChatId } from '../shared/active-chats.js';
15
+ import { setChatUser } from '../shared/chat-user-map.js';
13
16
  import { createLogger } from '../logger.js';
14
17
  const log = createLogger('FeishuHandler');
15
18
  async function downloadFeishuImage(client, imageKey) {
@@ -107,7 +110,7 @@ export function setupFeishuHandlers(config, sessionManager) {
107
110
  config,
108
111
  sessionManager,
109
112
  requestQueue,
110
- sender: { sendTextReply },
113
+ sender: { sendTextReply, sendModeCard },
111
114
  getRunningTasksSize: () => runningTasks.size,
112
115
  });
113
116
  registerPermissionSender('feishu', { sendTextReply, sendPermissionCard });
@@ -162,62 +165,181 @@ export function setupFeishuHandlers(config, sessionManager) {
162
165
  sendImage: (path) => sendImageReply(chatId, path),
163
166
  });
164
167
  }
168
+ /**
169
+ * Parse permission button value from card action (兼容多种格式)
170
+ */
171
+ function parsePermissionActionValue(raw) {
172
+ if (!raw)
173
+ return null;
174
+ let buttonValue;
175
+ if (typeof raw === 'string') {
176
+ try {
177
+ const parsed = JSON.parse(raw);
178
+ if (parsed.action === 'permission' && parsed.value)
179
+ buttonValue = parsed.value;
180
+ else if (raw.startsWith('allow_') || raw.startsWith('deny_'))
181
+ buttonValue = raw;
182
+ }
183
+ catch {
184
+ if (raw.startsWith('allow_') || raw.startsWith('deny_'))
185
+ buttonValue = raw;
186
+ }
187
+ }
188
+ else if (typeof raw === 'object' && raw !== null) {
189
+ const obj = raw;
190
+ if (obj.action === 'permission' && obj.value)
191
+ buttonValue = obj.value;
192
+ }
193
+ if (!buttonValue)
194
+ return null;
195
+ if (buttonValue.startsWith('allow_')) {
196
+ return { decision: 'allow', requestId: buttonValue.slice(6) };
197
+ }
198
+ if (buttonValue.startsWith('deny_')) {
199
+ return { decision: 'deny', requestId: buttonValue.slice(5) };
200
+ }
201
+ return null;
202
+ }
203
+ /**
204
+ * 解析 action value(兼容对象、JSON 字符串)
205
+ */
206
+ function parseActionValue(raw) {
207
+ if (!raw)
208
+ return null;
209
+ let obj = null;
210
+ if (typeof raw === 'string') {
211
+ try {
212
+ obj = JSON.parse(raw);
213
+ }
214
+ catch {
215
+ return null;
216
+ }
217
+ }
218
+ else if (typeof raw === 'object' && raw !== null) {
219
+ obj = raw;
220
+ }
221
+ return obj?.action && obj?.value ? obj : null;
222
+ }
223
+ /**
224
+ * 从卡片回调事件中提取延时更新 token(格式 c-xxxx)
225
+ * 飞书文档:从卡片交互返回内容获取,用于延时更新接口
226
+ */
227
+ function extractCardToken(data) {
228
+ const raw = data;
229
+ const event = (raw?.event ?? raw);
230
+ const action = event?.action;
231
+ const context = event?.context;
232
+ const candidates = [
233
+ event?.token,
234
+ event?.open_api_token,
235
+ raw?.token,
236
+ action?.token,
237
+ context?.token,
238
+ ].filter((t) => typeof t === 'string' && t.startsWith('c-'));
239
+ const token = candidates[0] ?? null;
240
+ if (!token) {
241
+ log.debug('[extractCardToken] No token found, event keys:', Object.keys(event ?? {}));
242
+ }
243
+ return token;
244
+ }
245
+ /**
246
+ * Handle card button click (card.action.trigger) - 需在 3 秒内返回响应
247
+ * 同步只返回 toast,避免 200672;用延时更新 API 异步替换为只读卡片,防止二次点击
248
+ */
249
+ async function handleCardAction(data) {
250
+ // 兼容 SDK 可能嵌套的 event 结构
251
+ const wrapped = data;
252
+ const event = (wrapped?.event ?? data);
253
+ const actionValue = event?.action?.value;
254
+ const chatId = event?.context?.open_chat_id ?? event?.context?.chat_id ?? event?.context?.open_id ?? '';
255
+ const userId = event?.sender?.sender_id?.open_id ?? '';
256
+ log.info(`[handleCardAction] chatId=${chatId}, userId=${userId}, actionValue=${JSON.stringify(actionValue)}`);
257
+ // 处理 mode 按钮(兼容 value 为对象或 JSON 字符串)
258
+ const modeAv = parseActionValue(actionValue);
259
+ if (modeAv?.action === 'mode' && modeAv.value) {
260
+ const mode = modeAv.value;
261
+ if (['ask', 'accept-edits', 'plan', 'yolo'].includes(mode)) {
262
+ setPermissionMode(userId, mode);
263
+ const toastContent = `✅ 已切换为 ${MODE_LABELS[mode]}`;
264
+ const label = MODE_LABELS[mode];
265
+ // 异步发送文本回复,不阻塞 3 秒内返回
266
+ const sendReply = () => {
267
+ if (chatId)
268
+ return sendTextReply(chatId, toastContent);
269
+ if (userId)
270
+ return sendTextReplyByOpenId(userId, toastContent);
271
+ log.warn('[handleCardAction] No chatId/userId, cannot send text reply');
272
+ };
273
+ const p = sendReply();
274
+ if (p)
275
+ p.catch((e) => log.warn('[handleCardAction] Send reply failed:', e));
276
+ // 同步只返回 toast,避免 200672(同步返回 card 格式易出错)
277
+ const cardToken = extractCardToken(data);
278
+ const readOnlyCard = createFeishuModeCardReadOnly(label);
279
+ if (cardToken && userId) {
280
+ // 延时更新:异步替换为只读卡片,防止二次点击
281
+ delayUpdateCard(cardToken, readOnlyCard, [userId]).catch((e) => log.warn('[handleCardAction] delayUpdateCard failed:', e));
282
+ }
283
+ else if (!cardToken) {
284
+ log.debug('[handleCardAction] No card token in event, cannot delay-update card');
285
+ }
286
+ return { toast: { type: 'success', content: toastContent } };
287
+ }
288
+ }
289
+ const parsed = parsePermissionActionValue(actionValue);
290
+ if (!parsed) {
291
+ log.info('[handleCardAction] Unrecognized action value, returning default toast');
292
+ return { toast: { type: 'warning', content: '未知操作' } };
293
+ }
294
+ const { decision, requestId } = parsed;
295
+ log.info(`[handleCardAction] Permission button: ${decision} for ${requestId}, chatId=${chatId}`);
296
+ const resolved = resolvePermissionById(requestId, decision);
297
+ const toastContent = resolved
298
+ ? decision === 'allow'
299
+ ? '✅ 权限已允许'
300
+ : '❌ 权限已拒绝'
301
+ : '⚠️ 权限请求已过期或不存在';
302
+ const sendPermReply = () => {
303
+ if (chatId)
304
+ return sendTextReply(chatId, toastContent);
305
+ if (userId)
306
+ return sendTextReplyByOpenId(userId, toastContent);
307
+ };
308
+ const permP = sendPermReply();
309
+ if (permP)
310
+ permP.catch((err) => log.warn('Failed to send permission reply:', err));
311
+ return { toast: { type: resolved ? 'success' : 'warning', content: toastContent } };
312
+ }
165
313
  async function handleEvent(data) {
166
- log.info('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 500));
314
+ log.info('[handleEvent] Called with data:', JSON.stringify(data).slice(0, 800));
167
315
  try {
168
- log.info('[handleEvent] Starting processing');
169
- // Parse the event data
170
- // Feishu event structure (long connection mode):
171
- // {
172
- // "event_type": "im.message.receive_v1",
173
- // "event_id": "...",
174
- // "tenant_key": "...",
175
- // "app_id": "...",
176
- // "message": { "chat_id": "...", "content": "...", ... },
177
- // "sender": { "sender_id": { "open_id": "..." } }
178
- // "action": { // For card button clicks
179
- // "action_id": "...",
180
- // "value": { "action": "permission", "value": "allow_xxx" }
181
- // }
182
- // }
183
- const event = data;
184
- const eventType = event?.event_type;
316
+ const raw = data;
317
+ const event = (raw?.event ?? raw);
318
+ const eventType = event?.event_type ?? event?.type;
185
319
  log.info('Feishu event type:', eventType);
186
- // Handle message received events
320
+ // 1. 卡片按钮点击 (card.action.trigger) - 需快速返回响应
321
+ if (eventType === 'card.action.trigger') {
322
+ const result = await handleCardAction(data);
323
+ return result ?? { toast: { type: 'success', content: '已处理' } };
324
+ }
325
+ // 2. 消息接收 (im.message.receive_v1)
187
326
  if (eventType === 'im.message.receive_v1') {
188
327
  log.info('[handleEvent] Processing im.message.receive_v1 event');
189
- // Check if this is a card button click event
190
- // For interactive cards, the action is in a different location
191
- if (event?.action) {
192
- const action = event.action;
193
- log.info('[handleEvent] Card action detected:', action);
194
- if (action?.value) {
195
- const actionValue = action.value;
196
- if (actionValue.action === 'permission' && actionValue.value) {
197
- const buttonValue = actionValue.value;
198
- let decision = null;
199
- let requestId = null;
200
- if (buttonValue.startsWith('allow_')) {
201
- decision = 'allow';
202
- requestId = buttonValue.slice(6);
203
- }
204
- else if (buttonValue.startsWith('deny_')) {
205
- decision = 'deny';
206
- requestId = buttonValue.slice(5);
207
- }
208
- if (decision && requestId) {
209
- log.info(`[handleEvent] Permission button clicked: ${decision} for ${requestId}`);
210
- const resolved = resolvePermissionById(requestId, decision);
211
- const chatId = event.message?.chat_id ?? '';
212
- if (resolved) {
213
- await sendTextReply(chatId, decision === 'allow' ? '✅ 权限已允许' : '❌ 权限已拒绝');
214
- }
215
- else {
216
- await sendTextReply(chatId, '⚠️ 权限请求已过期或不存在');
217
- }
218
- return;
219
- }
328
+ // 兼容:部分场景下卡片点击可能通过 im.message 携带 action
329
+ if (event?.action?.value) {
330
+ const parsed = parsePermissionActionValue(event.action.value);
331
+ if (parsed) {
332
+ const { decision, requestId } = parsed;
333
+ const chatId = event.message?.chat_id ?? '';
334
+ log.info(`[handleEvent] Permission (via msg): ${decision} for ${requestId}`);
335
+ const resolved = resolvePermissionById(requestId, decision);
336
+ if (resolved) {
337
+ await sendTextReply(chatId, decision === 'allow' ? '✅ 权限已允许' : '❌ 权限已拒绝');
220
338
  }
339
+ else {
340
+ await sendTextReply(chatId, '⚠️ 权限请求已过期或不存在');
341
+ }
342
+ return;
221
343
  }
222
344
  }
223
345
  const message = event?.message;
@@ -256,6 +378,7 @@ export function setupFeishuHandlers(config, sessionManager) {
256
378
  }
257
379
  log.info(`Access granted for sender: ${senderId}`);
258
380
  setActiveChatId('feishu', chatId);
381
+ setChatUser(chatId, senderId);
259
382
  // Handle different message types
260
383
  if (msgType === 'text') {
261
384
  const text = content.text?.trim() ?? '';
@@ -289,39 +412,42 @@ export function setupFeishuHandlers(config, sessionManager) {
289
412
  }
290
413
  else if (msgType === 'post') {
291
414
  // Feishu rich text/post messages - extract text content
292
- const post = content?.post;
415
+ // 支持 post.content zh_cn.content,content 可能是二维数组(段落→元素)
416
+ const post = content?.post
417
+ ?? content?.zh_cn;
418
+ const rawContent = post?.content;
293
419
  let text = '';
294
- if (post?.content && Array.isArray(post.content)) {
295
- // Log full structure for debugging
296
- log.info(`[MSG] Post content structure:`, JSON.stringify(post.content).slice(0, 500));
297
- // Extract text from rich text structure
298
- for (const section of post.content) {
299
- if (!section || typeof section !== 'object')
300
- continue;
301
- const tag = section?.tag;
302
- // Handle different content types
303
- if (tag === 'text' || tag === 'plain_text') {
304
- const t = section?.text ?? '';
305
- text += t;
420
+ function extractTextFromElement(el) {
421
+ if (!el || typeof el !== 'object')
422
+ return '';
423
+ const obj = el;
424
+ const tag = obj.tag;
425
+ if (tag === 'text' || tag === 'plain_text') {
426
+ return (obj.text ?? obj.content ?? '').toString();
427
+ }
428
+ if (tag === 'a')
429
+ return (obj.text ?? obj.content ?? '').toString();
430
+ if (tag === 'heading' || tag === 'heading1' || tag === 'heading2' || tag === 'heading3') {
431
+ const headingText = el.text;
432
+ if (typeof headingText === 'string')
433
+ return headingText;
434
+ if (Array.isArray(headingText)) {
435
+ return headingText.map(extractTextFromElement).join('');
306
436
  }
307
- else if (tag === 'heading' || tag === 'heading1' || tag === 'heading2' || tag === 'heading3') {
308
- // Handle headings - might be nested structure
309
- const headingText = section?.text;
310
- if (typeof headingText === 'string') {
311
- text += headingText;
312
- }
313
- else if (Array.isArray(headingText)) {
314
- // Nested text elements in heading
315
- for (const item of headingText) {
316
- if (item && typeof item === 'object' && 'text' in item) {
317
- text += item.text ?? '';
318
- }
319
- }
437
+ }
438
+ return '';
439
+ }
440
+ if (rawContent && Array.isArray(rawContent)) {
441
+ log.info(`[MSG] Post content structure:`, JSON.stringify(rawContent).slice(0, 500));
442
+ for (const section of rawContent) {
443
+ if (Array.isArray(section)) {
444
+ // 二维数组:段落内多个元素
445
+ for (const el of section) {
446
+ text += extractTextFromElement(el);
320
447
  }
321
448
  }
322
449
  else {
323
- // Log unhandled tags for debugging
324
- log.info(`[MSG] Unhandled post tag: ${tag}, section:`, JSON.stringify(section).slice(0, 200));
450
+ text += extractTextFromElement(section);
325
451
  }
326
452
  }
327
453
  }
@@ -8,9 +8,22 @@ export declare function createFeishuButtonCard(title: string, content: string, b
8
8
  value: string;
9
9
  type?: 'primary' | 'default';
10
10
  }>): string;
11
+ /** 只读模式卡片(无按钮,用于回调后替换原卡片防止二次点击) */
12
+ export declare function createFeishuModeCardReadOnly(currentMode: string): Record<string, unknown>;
13
+ /**
14
+ * 延时更新消息卡片(POST /open-apis/im/v1/cards/update)
15
+ * 用于在卡片回调 3 秒内无法完成时,异步替换卡片为只读版本,防止二次点击
16
+ * @param token 从卡片交互事件中获取的 token(格式 c-xxxx)
17
+ * @param card 卡片内容 { config, header, elements }
18
+ * @param openIds 非共享卡片需指定更新的用户 open_id 列表
19
+ */
20
+ export declare function delayUpdateCard(token: string, card: Record<string, unknown>, openIds?: string[]): Promise<void>;
21
+ export declare function sendModeCard(chatId: string, _userId: string, currentMode: string): Promise<void>;
11
22
  export declare function sendThinkingMessage(chatId: string, replyToMessageId: string | undefined, toolId?: string): Promise<string>;
12
23
  export declare function updateMessage(chatId: string, messageId: string, content: string, status: MessageStatus, note?: string, toolId?: string): Promise<void>;
13
24
  export declare function sendFinalMessages(chatId: string, messageId: string, fullContent: string, note: string, toolId?: string): Promise<void>;
14
25
  export declare function sendTextReply(chatId: string, text: string): Promise<void>;
26
+ /** 使用 open_id 发送(私聊时 context 可能只有 open_id) */
27
+ export declare function sendTextReplyByOpenId(openId: string, text: string): Promise<void>;
15
28
  export declare function sendImageReply(chatId: string, imagePath: string): Promise<void>;
16
29
  export declare function startTypingLoop(_chatId: string): () => void;
@@ -114,6 +114,108 @@ export function createFeishuButtonCard(title, content, buttons) {
114
114
  };
115
115
  return JSON.stringify(card);
116
116
  }
117
+ /** 只读模式卡片(无按钮,用于回调后替换原卡片防止二次点击) */
118
+ export function createFeishuModeCardReadOnly(currentMode) {
119
+ return {
120
+ config: { wide_screen_mode: true },
121
+ header: {
122
+ template: 'green',
123
+ title: { content: '🔐 权限模式', tag: 'plain_text' },
124
+ },
125
+ elements: [
126
+ {
127
+ tag: 'div',
128
+ text: {
129
+ tag: 'lark_md',
130
+ content: `**当前模式:** ${currentMode}\n\n✅ 已切换成功,发送 \`/mode\` 可再次切换。`,
131
+ },
132
+ },
133
+ ],
134
+ };
135
+ }
136
+ /**
137
+ * 延时更新消息卡片(POST /open-apis/im/v1/cards/update)
138
+ * 用于在卡片回调 3 秒内无法完成时,异步替换卡片为只读版本,防止二次点击
139
+ * @param token 从卡片交互事件中获取的 token(格式 c-xxxx)
140
+ * @param card 卡片内容 { config, header, elements }
141
+ * @param openIds 非共享卡片需指定更新的用户 open_id 列表
142
+ */
143
+ export async function delayUpdateCard(token, card, openIds) {
144
+ const accessToken = await getTenantAccessToken();
145
+ // 非共享卡片需在 card 内指定 open_ids
146
+ const cardBody = { ...card };
147
+ if (openIds && openIds.length > 0) {
148
+ cardBody.open_ids = openIds;
149
+ }
150
+ const body = { token, card: cardBody };
151
+ const resp = await fetch('https://open.feishu.cn/open-apis/interactive/v1/card/update', {
152
+ method: 'POST',
153
+ headers: {
154
+ 'Content-Type': 'application/json',
155
+ Authorization: `Bearer ${accessToken}`,
156
+ },
157
+ body: JSON.stringify(body),
158
+ });
159
+ const data = (await resp.json());
160
+ if (data.code !== 0) {
161
+ log.warn(`[delayUpdateCard] Failed: code=${data.code}, msg=${data.msg}`);
162
+ return;
163
+ }
164
+ log.info('[delayUpdateCard] Card updated successfully');
165
+ }
166
+ /**
167
+ * Create mode switch card with action type for card callback
168
+ */
169
+ function createFeishuModeCard(currentMode, buttons) {
170
+ const elements = [];
171
+ elements.push({
172
+ tag: 'div',
173
+ text: {
174
+ tag: 'lark_md',
175
+ content: `**当前模式:** ${currentMode}\n\n点击下方按钮切换模式:\n\n_💡 若点击报错:开放平台 → 事件与回调 → 切到「回调」Tab → 添加「卡片回传交互」。或直接用 \`/mode ask\` 等命令切换。_`,
176
+ },
177
+ });
178
+ elements.push({ tag: 'hr' });
179
+ for (let i = 0; i < buttons.length; i += 4) {
180
+ const row = buttons.slice(i, i + 4).map((btn) => ({
181
+ tag: 'button',
182
+ text: { tag: 'plain_text', content: btn.label },
183
+ type: btn.type || 'default',
184
+ value: { action: 'mode', value: btn.value },
185
+ }));
186
+ elements.push({ tag: 'action', actions: row });
187
+ }
188
+ const card = {
189
+ config: { wide_screen_mode: true },
190
+ header: {
191
+ template: 'blue',
192
+ title: { content: '🔐 权限模式', tag: 'plain_text' },
193
+ },
194
+ elements,
195
+ };
196
+ return JSON.stringify(card);
197
+ }
198
+ export async function sendModeCard(chatId, _userId, currentMode) {
199
+ const { getClient } = await import('./client.js');
200
+ const { MODE_LABELS } = await import('../permission-mode/types.js');
201
+ const client = getClient();
202
+ const MODE_BTNS = [
203
+ { label: MODE_LABELS.ask, value: 'ask', type: 'default' },
204
+ { label: MODE_LABELS['accept-edits'], value: 'accept-edits', type: 'default' },
205
+ { label: MODE_LABELS.plan, value: 'plan', type: 'default' },
206
+ { label: MODE_LABELS.yolo, value: 'yolo', type: 'default' },
207
+ ];
208
+ const currentLabel = MODE_BTNS.find((b) => b.value === currentMode)?.label ?? currentMode;
209
+ const cardContent = createFeishuModeCard(currentLabel, MODE_BTNS);
210
+ await client.im.message.create({
211
+ data: {
212
+ receive_id: chatId,
213
+ msg_type: 'interactive',
214
+ content: cardContent,
215
+ },
216
+ params: { receive_id_type: 'chat_id' },
217
+ });
218
+ }
117
219
  async function getTenantAccessToken() {
118
220
  const client = getClient();
119
221
  const resp = await client.auth.tenantAccessToken.internal({
@@ -153,8 +255,7 @@ export async function sendThinkingMessage(chatId, replyToMessageId, toolId = 'cl
153
255
  }
154
256
  export async function updateMessage(chatId, messageId, content, status, note, toolId = 'claude') {
155
257
  const client = getClient();
156
- const icon = STATUS_CONFIG[status].icon;
157
- const title = `${icon} ${getToolTitle(toolId, status)}`;
258
+ const title = getToolTitle(toolId, status);
158
259
  const cardContent = createFeishuCard(title, content, status, note);
159
260
  // Try to use patch API for in-place update (streaming)
160
261
  try {
@@ -276,6 +377,24 @@ export async function sendTextReply(chatId, text) {
276
377
  log.error('Failed to send text:', err);
277
378
  }
278
379
  }
380
+ /** 使用 open_id 发送(私聊时 context 可能只有 open_id) */
381
+ export async function sendTextReplyByOpenId(openId, text) {
382
+ const client = getClient();
383
+ const cardContent = createFeishuCard('📢 open-im', text, 'done');
384
+ try {
385
+ await client.im.message.create({
386
+ data: {
387
+ receive_id: openId,
388
+ msg_type: 'interactive',
389
+ content: cardContent,
390
+ },
391
+ params: { receive_id_type: 'open_id' },
392
+ });
393
+ }
394
+ catch (err) {
395
+ log.error('Failed to send text by open_id:', err);
396
+ }
397
+ }
279
398
  export async function sendImageReply(chatId, imagePath) {
280
399
  const client = getClient();
281
400
  try {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Permission Server - Handles tool permission requests from Claude CLI
3
3
  *
4
- * When claudeSkipPermissions is false, Claude CLI will make HTTP requests
5
- * to this server to ask for user approval before running tools.
4
+ * When claudeSkipPermissions is false and not in yolo mode, Claude CLI will make
5
+ * HTTP requests to this server. We forward all requests to the user for approval;
6
+ * permission mode logic (ask/accept-edits/plan) is handled by Claude via --permission-mode.
6
7
  */
7
8
  interface MessageSender {
8
9
  sendTextReply(chatId: string, text: string): Promise<void>;
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Permission Server - Handles tool permission requests from Claude CLI
3
3
  *
4
- * When claudeSkipPermissions is false, Claude CLI will make HTTP requests
5
- * to this server to ask for user approval before running tools.
4
+ * When claudeSkipPermissions is false and not in yolo mode, Claude CLI will make
5
+ * HTTP requests to this server. We forward all requests to the user for approval;
6
+ * permission mode logic (ask/accept-edits/plan) is handled by Claude via --permission-mode.
6
7
  */
7
8
  import { createServer } from 'node:http';
8
9
  import { createLogger } from '../logger.js';
@@ -148,35 +149,36 @@ function handlePermissionRequest(req, res) {
148
149
  req.on('end', async () => {
149
150
  try {
150
151
  const data = JSON.parse(body);
151
- const { requestId, toolName, toolInput } = data;
152
+ const requestId = String(data.requestId ?? '');
153
+ const toolName = String(data.toolName ?? '');
154
+ const toolInput = data.toolInput;
155
+ const chatId = (typeof data.chatId === 'string' ? data.chatId : null) ??
156
+ (typeof data.chat_id === 'string' ? data.chat_id : null) ??
157
+ process.env.CC_IM_CHAT_ID ??
158
+ undefined;
152
159
  if (!requestId || !toolName) {
153
160
  res.writeHead(400, { 'Content-Type': 'application/json' });
154
161
  res.end(JSON.stringify({ error: 'Missing required fields' }));
155
162
  return;
156
163
  }
157
- // Get chatId from environment (set by CC_IM_CHAT_ID)
158
- const chatId = process.env.CC_IM_CHAT_ID;
159
164
  if (!chatId) {
160
- log.warn('No chatId in environment, auto-allowing permission');
165
+ log.warn('No chatId, auto-allowing permission');
161
166
  res.writeHead(200, { 'Content-Type': 'application/json' });
162
167
  res.end(JSON.stringify({ allowed: true }));
163
168
  return;
164
169
  }
165
- // Check if skip permissions is enabled
166
170
  if (process.env.CC_SKIP_PERMISSIONS === 'true') {
167
171
  log.info(`Skip permissions enabled, auto-allowing ${toolName}`);
168
172
  res.writeHead(200, { 'Content-Type': 'application/json' });
169
173
  res.end(JSON.stringify({ allowed: true }));
170
174
  return;
171
175
  }
172
- // Check if already resolved (race condition)
173
176
  if (pendingRequests.has(requestId)) {
174
177
  log.warn(`Duplicate permission request: ${requestId}`);
175
178
  res.writeHead(409, { 'Content-Type': 'application/json' });
176
179
  res.end(JSON.stringify({ error: 'Duplicate request' }));
177
180
  return;
178
181
  }
179
- // Send pending response
180
182
  res.writeHead(202, { 'Content-Type': 'application/json' });
181
183
  res.end(JSON.stringify({ status: 'pending' }));
182
184
  // Create permission request
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { loadActiveChats, getActiveChatId, flushActiveChats, } from "./shared/ac
17
17
  import { initLogger, createLogger, closeLogger } from "./logger.js";
18
18
  import { APP_HOME, SHUTDOWN_PORT } from "./constants.js";
19
19
  import { startPermissionServer, stopPermissionServer } from "./hook/permission-server.js";
20
+ import { initPermissionModes } from "./permission-mode/session-mode.js";
20
21
  import { createRequire } from "node:module";
21
22
  const require = createRequire(import.meta.url);
22
23
  const { version: APP_VERSION } = require("../package.json");
@@ -46,13 +47,17 @@ export async function main() {
46
47
  const config = loadConfig();
47
48
  initLogger(config.logDir, config.logLevel);
48
49
  loadActiveChats();
50
+ initPermissionModes();
49
51
  initAdapters(config);
50
52
  // Start permission server for tool approval
51
53
  const actualPort = startPermissionServer(config.hookPort);
52
54
  log.info(`Permission server listening on port ${actualPort}`);
55
+ const { MODE_LABELS } = await import('./permission-mode/types.js');
56
+ const defaultModeLabel = MODE_LABELS[config.defaultPermissionMode];
53
57
  log.info("Starting open-im bridge...");
54
58
  log.info(`AI 工具: ${config.aiCommand}`);
55
59
  log.info(`工作目录: ${config.claudeWorkDir}`);
60
+ log.info(`默认权限模式: ${defaultModeLabel} (${config.defaultPermissionMode})`);
56
61
  log.info(`启用平台: ${config.enabledPlatforms.join(", ")}`);
57
62
  const sessionManager = new SessionManager(config.claudeWorkDir, config.allowedBaseDirs);
58
63
  let telegramHandle = null;
@@ -72,6 +77,7 @@ export async function main() {
72
77
  "",
73
78
  `AI 工具: ${config.aiCommand}`,
74
79
  `工作目录: ${config.claudeWorkDir}`,
80
+ `默认权限模式: ${defaultModeLabel} (${config.defaultPermissionMode})`,
75
81
  `启用平台: ${config.enabledPlatforms.join(", ")}`,
76
82
  ].join("\n");
77
83
  // Send notification to all enabled platforms
@@ -0,0 +1,7 @@
1
+ import type { PermissionMode } from './types.js';
2
+ /** 初始化(启动时调用) */
3
+ export declare function initPermissionModes(): void;
4
+ /** 获取用户当前权限模式 */
5
+ export declare function getPermissionMode(userId: string, defaultMode?: PermissionMode): PermissionMode;
6
+ /** 设置用户权限模式 */
7
+ export declare function setPermissionMode(userId: string, mode: PermissionMode): void;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * 按用户存储权限模式,支持运行时切换
3
+ */
4
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
5
+ import { dirname, join } from 'node:path';
6
+ import { APP_HOME } from '../constants.js';
7
+ import { createLogger } from '../logger.js';
8
+ import { PERMISSION_MODES } from './types.js';
9
+ const log = createLogger('PermissionMode');
10
+ const MODE_FILE = join(APP_HOME, 'data', 'permission-modes.json');
11
+ let modes = new Map();
12
+ let saveTimer = null;
13
+ function isValidMode(v) {
14
+ return typeof v === 'string' && PERMISSION_MODES.includes(v);
15
+ }
16
+ function load() {
17
+ try {
18
+ if (existsSync(MODE_FILE)) {
19
+ const data = JSON.parse(readFileSync(MODE_FILE, 'utf-8'));
20
+ modes = new Map(Object.entries(data).filter(([, v]) => isValidMode(v)));
21
+ }
22
+ }
23
+ catch {
24
+ modes = new Map();
25
+ }
26
+ }
27
+ function scheduleSave() {
28
+ if (saveTimer)
29
+ return;
30
+ saveTimer = setTimeout(() => {
31
+ saveTimer = null;
32
+ try {
33
+ const dir = dirname(MODE_FILE);
34
+ if (!existsSync(dir))
35
+ mkdirSync(dir, { recursive: true });
36
+ const obj = {};
37
+ for (const [k, v] of modes)
38
+ obj[k] = v;
39
+ writeFileSync(MODE_FILE, JSON.stringify(obj, null, 2), 'utf-8');
40
+ }
41
+ catch (err) {
42
+ log.error('Failed to save permission modes:', err);
43
+ }
44
+ }, 300);
45
+ }
46
+ /** 初始化(启动时调用) */
47
+ export function initPermissionModes() {
48
+ load();
49
+ }
50
+ /** 获取用户当前权限模式 */
51
+ export function getPermissionMode(userId, defaultMode = 'ask') {
52
+ return modes.get(userId) ?? defaultMode;
53
+ }
54
+ /** 设置用户权限模式 */
55
+ export function setPermissionMode(userId, mode) {
56
+ modes.set(userId, mode);
57
+ scheduleSave();
58
+ log.info(`Permission mode for ${userId}: ${mode}`);
59
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 权限模式类型定义
3
+ * 与 Claude Code 官方命名一致: default | acceptEdits | plan | bypassPermissions
4
+ * 见 https://code.claude.com/docs/en/permissions
5
+ */
6
+ export type PermissionMode = 'ask' | 'accept-edits' | 'plan' | 'yolo';
7
+ export declare const PERMISSION_MODES: PermissionMode[];
8
+ /** Claude Code 官方模式名(用于显示) */
9
+ export declare const MODE_LABELS: Record<PermissionMode, string>;
10
+ export declare const MODE_DESCRIPTIONS: Record<PermissionMode, string>;
11
+ export declare function parsePermissionMode(raw: string): PermissionMode | null;
@@ -0,0 +1,29 @@
1
+ export const PERMISSION_MODES = ['ask', 'accept-edits', 'plan', 'yolo'];
2
+ /** Claude Code 官方模式名(用于显示) */
3
+ export const MODE_LABELS = {
4
+ ask: 'default',
5
+ 'accept-edits': 'acceptEdits',
6
+ plan: 'plan',
7
+ yolo: 'bypassPermissions',
8
+ };
9
+ export const MODE_DESCRIPTIONS = {
10
+ ask: '首次使用每个工具时提示确认',
11
+ 'accept-edits': '编辑权限自动通过',
12
+ plan: '仅分析,不修改文件不执行命令',
13
+ yolo: '跳过所有权限确认',
14
+ };
15
+ export function parsePermissionMode(raw) {
16
+ const s = raw.trim().toLowerCase();
17
+ if (PERMISSION_MODES.includes(s))
18
+ return s;
19
+ const aliases = {
20
+ safe: 'ask',
21
+ default: 'ask',
22
+ edit: 'accept-edits',
23
+ edits: 'accept-edits',
24
+ read: 'plan',
25
+ readonly: 'plan',
26
+ bypass: 'yolo',
27
+ };
28
+ return aliases[s] ?? null;
29
+ }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * 共享 AI 任务执行层 - 支持多 ToolAdapter
3
3
  */
4
+ import { getPermissionMode } from '../permission-mode/session-mode.js';
4
5
  import { formatToolStats, formatToolCallNotification, getContextWarning, } from './utils.js';
5
6
  import { createLogger } from '../logger.js';
6
7
  const log = createLogger('AITask');
@@ -69,6 +70,19 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
69
70
  }, platformAdapter.throttleMs - elapsed);
70
71
  }
71
72
  };
73
+ const mode = getPermissionMode(ctx.userId, config.defaultPermissionMode);
74
+ // 全部交给 Claude 自己处理:yolo 用 --dangerously-skip-permissions,其他用 --permission-mode
75
+ const skipPermissions = mode === 'yolo' || config.claudeSkipPermissions;
76
+ const permissionMode = !skipPermissions
77
+ ? (mode === 'ask'
78
+ ? 'default'
79
+ : mode === 'accept-edits'
80
+ ? 'acceptEdits'
81
+ : mode === 'plan'
82
+ ? 'plan'
83
+ : undefined)
84
+ : undefined;
85
+ process.env.CC_IM_CHAT_ID = ctx.chatId;
72
86
  const handle = toolAdapter.run(prompt, ctx.sessionId, ctx.workDir, {
73
87
  onSessionId: (id) => {
74
88
  if (ctx.threadId)
@@ -149,7 +163,8 @@ export function runAITask(deps, ctx, prompt, toolAdapter, platformAdapter) {
149
163
  resolve();
150
164
  },
151
165
  }, {
152
- skipPermissions: config.claudeSkipPermissions,
166
+ skipPermissions,
167
+ permissionMode,
153
168
  timeoutMs: config.claudeTimeoutMs,
154
169
  model: sessionManager.getModel(ctx.userId, ctx.threadId) ?? config.claudeModel,
155
170
  chatId: ctx.chatId,
@@ -0,0 +1,2 @@
1
+ export declare function setChatUser(chatId: string, userId: string): void;
2
+ export declare function getUserIdByChatId(chatId: string): string | undefined;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * chatId -> userId 映射,用于权限请求时根据 chatId 查找用户
3
+ * 在用户发送消息时更新
4
+ */
5
+ const chatToUser = new Map();
6
+ export function setChatUser(chatId, userId) {
7
+ chatToUser.set(chatId, userId);
8
+ }
9
+ export function getUserIdByChatId(chatId) {
10
+ return chatToUser.get(chatId);
11
+ }
@@ -3,7 +3,7 @@ import { join } from "node:path";
3
3
  import { message } from "telegraf/filters";
4
4
  import { AccessControl } from "../access/access-control.js";
5
5
  import { RequestQueue } from "../queue/request-queue.js";
6
- import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, startTypingLoop, sendImageReply, } from "./message-sender.js";
6
+ import { sendThinkingMessage, updateMessage, sendFinalMessages, sendTextReply, startTypingLoop, sendImageReply, sendModeKeyboard, sendDirectorySelection, } from "./message-sender.js";
7
7
  import { registerPermissionSender, resolvePermissionById, } from "../hook/permission-server.js";
8
8
  import { CommandHandler } from "../commands/handler.js";
9
9
  import { getAdapter } from "../adapters/registry.js";
@@ -11,6 +11,9 @@ import { runAITask } from "../shared/ai-task.js";
11
11
  import { startTaskCleanup } from "../shared/task-cleanup.js";
12
12
  import { TELEGRAM_THROTTLE_MS, IMAGE_DIR } from "../constants.js";
13
13
  import { setActiveChatId } from "../shared/active-chats.js";
14
+ import { setChatUser } from "../shared/chat-user-map.js";
15
+ import { setPermissionMode } from "../permission-mode/session-mode.js";
16
+ import { MODE_LABELS } from "../permission-mode/types.js";
14
17
  import { createLogger } from "../logger.js";
15
18
  const log = createLogger("TgHandler");
16
19
  // 动态节流器类 - 根据内容长度和更新频率调整间隔
@@ -74,7 +77,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
74
77
  config,
75
78
  sessionManager,
76
79
  requestQueue,
77
- sender: { sendTextReply },
80
+ sender: { sendTextReply, sendDirectorySelection, sendModeKeyboard },
78
81
  getRunningTasksSize: () => runningTasks.size,
79
82
  });
80
83
  registerPermissionSender("telegram", { sendTextReply });
@@ -310,6 +313,16 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
310
313
  resolvePermissionById(requestId, decision);
311
314
  await ctx.answerCbQuery(isAllow ? "✅ 已允许" : "❌ 已拒绝");
312
315
  }
316
+ else if (data.startsWith("mode:")) {
317
+ const parts = data.split(":");
318
+ if (parts.length >= 3 && parts[1] === userId) {
319
+ const mode = parts[2];
320
+ if (["ask", "accept-edits", "plan", "yolo"].includes(mode)) {
321
+ setPermissionMode(userId, mode);
322
+ await ctx.answerCbQuery(`✅ 已切换为 ${MODE_LABELS[mode]}`);
323
+ }
324
+ }
325
+ }
313
326
  });
314
327
  bot.on(message("text"), async (ctx) => {
315
328
  const chatId = String(ctx.chat.id);
@@ -321,6 +334,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
321
334
  return;
322
335
  }
323
336
  setActiveChatId("telegram", chatId);
337
+ setChatUser(chatId, userId);
324
338
  if (await commandHandler.dispatch(text, chatId, userId, "telegram", handleAIRequest)) {
325
339
  return;
326
340
  }
@@ -343,6 +357,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
343
357
  if (!accessControl.isAllowed(userId))
344
358
  return;
345
359
  setActiveChatId("telegram", chatId);
360
+ setChatUser(chatId, userId);
346
361
  const photos = ctx.message.photo;
347
362
  const largest = photos[photos.length - 1];
348
363
  let imagePath;
@@ -8,4 +8,5 @@ export declare function sendImageReply(chatId: string, imagePath: string): Promi
8
8
  * 发送目录选择界面
9
9
  */
10
10
  export declare function sendDirectorySelection(chatId: string, currentDir: string, userId: string): Promise<void>;
11
+ export declare function sendModeKeyboard(chatId: string, userId: string, currentMode: string): Promise<void>;
11
12
  export declare function startTypingLoop(chatId: string): () => void;
@@ -182,6 +182,20 @@ export async function sendDirectorySelection(chatId, currentDir, userId) {
182
182
  reply_markup: keyboard,
183
183
  });
184
184
  }
185
+ export async function sendModeKeyboard(chatId, userId, currentMode) {
186
+ const bot = getBot();
187
+ const { MODE_LABELS } = await import("../permission-mode/types.js");
188
+ const modes = ["ask", "accept-edits", "plan", "yolo"];
189
+ const keyboard = {
190
+ inline_keyboard: [
191
+ modes.map((m) => ({
192
+ text: currentMode === m ? `✓ ${MODE_LABELS[m]}` : MODE_LABELS[m],
193
+ callback_data: `mode:${userId}:${m}`,
194
+ })),
195
+ ],
196
+ };
197
+ await bot.telegram.sendMessage(Number(chatId), `🔐 **权限模式** (当前: ${MODE_LABELS[currentMode] ?? currentMode})\n\n点击切换:`, { parse_mode: "Markdown", reply_markup: keyboard });
198
+ }
185
199
  export function startTypingLoop(chatId) {
186
200
  const bot = getBot();
187
201
  const interval = setInterval(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.0.2-beta.3",
3
+ "version": "1.0.2",
4
4
  "description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, Cursor)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",