cc-im 1.0.0
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/.env.example +43 -0
- package/README.md +219 -0
- package/dist/access/access-control.js +12 -0
- package/dist/claude/cli-runner.js +215 -0
- package/dist/claude/stream-parser.js +42 -0
- package/dist/claude/types.js +30 -0
- package/dist/cli.js +102 -0
- package/dist/commands/handler.js +439 -0
- package/dist/config.js +151 -0
- package/dist/constants.js +94 -0
- package/dist/feishu/card-builder.js +172 -0
- package/dist/feishu/cardkit-manager.js +208 -0
- package/dist/feishu/client.js +25 -0
- package/dist/feishu/event-handler.js +527 -0
- package/dist/feishu/message-sender.js +201 -0
- package/dist/hook/hook-script.js +124 -0
- package/dist/hook/permission-server.js +206 -0
- package/dist/index.js +172 -0
- package/dist/logger.js +80 -0
- package/dist/queue/request-queue.js +51 -0
- package/dist/sanitize.js +43 -0
- package/dist/session/session-manager.js +283 -0
- package/dist/shared/active-chats.js +44 -0
- package/dist/shared/types.js +4 -0
- package/dist/shared/utils.js +164 -0
- package/dist/telegram/client.js +30 -0
- package/dist/telegram/event-handler.js +343 -0
- package/dist/telegram/message-sender.js +223 -0
- package/package.json +50 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { getClient } from './client.js';
|
|
2
|
+
import { buildCardV2, buildPermissionCard, buildPermissionResultCard, splitLongContent, truncateForStreaming, } from './card-builder.js';
|
|
3
|
+
import { createCard, enableStreaming, sendCardMessage, replyCardMessage, streamContent as cardkitStreamContent, updateCardFull, markCompleted, disableStreaming, destroySession, } from './cardkit-manager.js';
|
|
4
|
+
import { createLogger } from '../logger.js';
|
|
5
|
+
const log = createLogger('MessageSender');
|
|
6
|
+
/**
|
|
7
|
+
* 获取话题描述(即话题根消息的内容)
|
|
8
|
+
*/
|
|
9
|
+
export async function fetchThreadDescription(rootMessageId) {
|
|
10
|
+
const client = getClient();
|
|
11
|
+
try {
|
|
12
|
+
const res = await client.im.v1.message.get({
|
|
13
|
+
path: { message_id: rootMessageId },
|
|
14
|
+
});
|
|
15
|
+
if (res.code !== 0) {
|
|
16
|
+
log.warn(`Failed to fetch root message ${rootMessageId}: code=${res.code}, msg=${res.msg}`);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
const message = res.data?.items?.[0] ?? res.data;
|
|
20
|
+
if (!message)
|
|
21
|
+
return undefined;
|
|
22
|
+
const msg = message;
|
|
23
|
+
const msgType = msg.msg_type;
|
|
24
|
+
const body = msg.body;
|
|
25
|
+
const content = (body?.content ?? msg.content);
|
|
26
|
+
if (!content)
|
|
27
|
+
return undefined;
|
|
28
|
+
if (msgType === 'text') {
|
|
29
|
+
const parsed = JSON.parse(content);
|
|
30
|
+
return parsed.text || undefined;
|
|
31
|
+
}
|
|
32
|
+
if (msgType === 'post') {
|
|
33
|
+
const parsed = JSON.parse(content);
|
|
34
|
+
// 接收到的 post 结构(无 locale 包装): { "title": "...", "content": [[{tag, text}, ...]] }
|
|
35
|
+
const title = parsed.title;
|
|
36
|
+
const paragraphs = parsed.content;
|
|
37
|
+
const bodyText = paragraphs
|
|
38
|
+
?.map((line) => line.filter(el => el.text).map(el => el.text).join(''))
|
|
39
|
+
.join('\n')
|
|
40
|
+
.trim();
|
|
41
|
+
return title || bodyText || undefined;
|
|
42
|
+
}
|
|
43
|
+
return `[${msgType}]`;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
log.error(`Error fetching root message ${rootMessageId}:`, err);
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
export async function sendThinkingCard(chatId, threadCtx) {
|
|
51
|
+
// 1. 创建 CardKit 卡片(初始无停止按钮)
|
|
52
|
+
const initialCard = buildCardV2({ content: '正在启动...', status: 'processing', note: '请稍候' });
|
|
53
|
+
const cardId = await createCard(initialCard);
|
|
54
|
+
let messageId;
|
|
55
|
+
if (threadCtx) {
|
|
56
|
+
// 话题模式:用 reply API 发送到话题
|
|
57
|
+
const [, result] = await Promise.all([
|
|
58
|
+
enableStreaming(cardId),
|
|
59
|
+
replyCardMessage(threadCtx.rootMessageId, cardId),
|
|
60
|
+
]);
|
|
61
|
+
messageId = result.messageId;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
// 非话题模式:保持现有逻辑
|
|
65
|
+
const [, mid] = await Promise.all([
|
|
66
|
+
enableStreaming(cardId),
|
|
67
|
+
sendCardMessage(chatId, cardId),
|
|
68
|
+
]);
|
|
69
|
+
messageId = mid;
|
|
70
|
+
}
|
|
71
|
+
// 3. 全量更新补充停止按钮(现在有 cardId 了)
|
|
72
|
+
const cardWithButton = buildCardV2({ content: '等待 Claude 响应...', status: 'processing', note: '请稍候' }, cardId);
|
|
73
|
+
await updateCardFull(cardId, cardWithButton);
|
|
74
|
+
log.debug(`Processing card created: cardId=${cardId}, messageId=${messageId}`);
|
|
75
|
+
return { messageId, cardId };
|
|
76
|
+
}
|
|
77
|
+
export async function streamContentUpdate(cardId, content, note) {
|
|
78
|
+
const truncated = truncateForStreaming(content) || '...';
|
|
79
|
+
const updates = [cardkitStreamContent(cardId, 'main_content', truncated)];
|
|
80
|
+
if (note)
|
|
81
|
+
updates.push(cardkitStreamContent(cardId, 'note_area', note));
|
|
82
|
+
await Promise.all(updates);
|
|
83
|
+
}
|
|
84
|
+
export async function sendFinalCards(chatId, messageId, cardId, fullContent, note, threadCtx, thinking) {
|
|
85
|
+
const parts = splitLongContent(fullContent);
|
|
86
|
+
// 标记卡片为已完成,阻止 streamContent 重试重新启用 streaming
|
|
87
|
+
markCompleted(cardId);
|
|
88
|
+
// 显式关闭流式模式(card.update 不会自动关闭 card.settings 开启的 streaming_mode)
|
|
89
|
+
await disableStreaming(cardId);
|
|
90
|
+
// 更新原卡片为完成状态
|
|
91
|
+
const finalCard = buildCardV2({ content: parts[0], status: 'done', note, thinking });
|
|
92
|
+
await updateCardFull(cardId, finalCard);
|
|
93
|
+
// 溢出部分用新消息发送
|
|
94
|
+
const client = getClient();
|
|
95
|
+
for (let i = 1; i < parts.length; i++) {
|
|
96
|
+
const overflowCard = buildCardV2({
|
|
97
|
+
content: parts[i],
|
|
98
|
+
status: 'done',
|
|
99
|
+
note: `(续 ${i + 1}/${parts.length}) ${note}`,
|
|
100
|
+
});
|
|
101
|
+
if (threadCtx) {
|
|
102
|
+
await client.im.v1.message.reply({
|
|
103
|
+
path: { message_id: threadCtx.rootMessageId },
|
|
104
|
+
data: {
|
|
105
|
+
content: overflowCard,
|
|
106
|
+
msg_type: 'interactive',
|
|
107
|
+
reply_in_thread: true,
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
await client.im.v1.message.create({
|
|
113
|
+
params: { receive_id_type: 'chat_id' },
|
|
114
|
+
data: {
|
|
115
|
+
receive_id: chatId,
|
|
116
|
+
content: overflowCard,
|
|
117
|
+
msg_type: 'interactive',
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
destroySession(cardId);
|
|
123
|
+
}
|
|
124
|
+
export async function sendErrorCard(cardId, error) {
|
|
125
|
+
markCompleted(cardId);
|
|
126
|
+
await disableStreaming(cardId);
|
|
127
|
+
try {
|
|
128
|
+
const errorCard = buildCardV2({ content: `错误:${error}`, status: 'error', note: '执行失败' });
|
|
129
|
+
await updateCardFull(cardId, errorCard);
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
log.error('Failed to send error card:', err);
|
|
133
|
+
}
|
|
134
|
+
destroySession(cardId);
|
|
135
|
+
}
|
|
136
|
+
export async function sendTextReply(chatId, text, threadCtx) {
|
|
137
|
+
const client = getClient();
|
|
138
|
+
try {
|
|
139
|
+
if (threadCtx) {
|
|
140
|
+
await client.im.v1.message.reply({
|
|
141
|
+
path: { message_id: threadCtx.rootMessageId },
|
|
142
|
+
data: {
|
|
143
|
+
content: JSON.stringify({ text }),
|
|
144
|
+
msg_type: 'text',
|
|
145
|
+
reply_in_thread: true,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
await client.im.v1.message.create({
|
|
151
|
+
params: { receive_id_type: 'chat_id' },
|
|
152
|
+
data: {
|
|
153
|
+
receive_id: chatId,
|
|
154
|
+
content: JSON.stringify({ text }),
|
|
155
|
+
msg_type: 'text',
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
log.error('Failed to send text reply:', err);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
export async function sendPermissionCard(chatId, requestId, toolName, toolInput, threadCtx) {
|
|
165
|
+
const client = getClient();
|
|
166
|
+
const content = buildPermissionCard(requestId, toolName, toolInput);
|
|
167
|
+
if (threadCtx) {
|
|
168
|
+
const res = await client.im.v1.message.reply({
|
|
169
|
+
path: { message_id: threadCtx.rootMessageId },
|
|
170
|
+
data: {
|
|
171
|
+
content,
|
|
172
|
+
msg_type: 'interactive',
|
|
173
|
+
reply_in_thread: true,
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return res.data?.message_id ?? '';
|
|
177
|
+
}
|
|
178
|
+
const res = await client.im.v1.message.create({
|
|
179
|
+
params: { receive_id_type: 'chat_id' },
|
|
180
|
+
data: {
|
|
181
|
+
receive_id: chatId,
|
|
182
|
+
content,
|
|
183
|
+
msg_type: 'interactive',
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
return res.data?.message_id ?? '';
|
|
187
|
+
}
|
|
188
|
+
export async function updatePermissionCard(messageId, toolName, decision) {
|
|
189
|
+
const client = getClient();
|
|
190
|
+
try {
|
|
191
|
+
await client.im.v1.message.patch({
|
|
192
|
+
path: { message_id: messageId },
|
|
193
|
+
data: {
|
|
194
|
+
content: buildPermissionResultCard(toolName, decision),
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch (err) {
|
|
199
|
+
log.error('Failed to update permission card:', err);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse hook script.
|
|
4
|
+
*
|
|
5
|
+
* This script is invoked by Claude Code before each tool execution.
|
|
6
|
+
* It sends a permission request to the cc-im permission server,
|
|
7
|
+
* which notifies the user via the messaging platform and waits for their decision.
|
|
8
|
+
*
|
|
9
|
+
* Environment variables:
|
|
10
|
+
* CC_BOT_CHAT_ID - Chat ID to send the permission card to
|
|
11
|
+
* CC_BOT_HOOK_PORT - Port of the local permission server (default: 18900)
|
|
12
|
+
*
|
|
13
|
+
* stdin: JSON { session_id, tool_name, tool_input }
|
|
14
|
+
* stdout: JSON { permissionDecision: "allow" | "deny" }
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 - Success (decision written to stdout)
|
|
18
|
+
* 1 - General error (input parsing failed, etc.)
|
|
19
|
+
* 2 - Permission server unreachable (deny decision written to stdout)
|
|
20
|
+
*/
|
|
21
|
+
import { request } from 'node:http';
|
|
22
|
+
import { READ_ONLY_TOOLS, HOOK_EXIT_CODES } from '../constants.js';
|
|
23
|
+
function readStdin() {
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let data = '';
|
|
26
|
+
process.stdin.setEncoding('utf-8');
|
|
27
|
+
process.stdin.on('data', (chunk) => { data += chunk; });
|
|
28
|
+
process.stdin.on('end', () => resolve(data));
|
|
29
|
+
// If stdin is empty/closed immediately
|
|
30
|
+
setTimeout(() => resolve(data), 100);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function httpPost(port, path, body) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const payload = JSON.stringify(body);
|
|
36
|
+
const req = request({
|
|
37
|
+
hostname: '127.0.0.1',
|
|
38
|
+
port,
|
|
39
|
+
path,
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Content-Length': Buffer.byteLength(payload),
|
|
44
|
+
},
|
|
45
|
+
timeout: 6 * 60 * 1000, // 6 minutes (server has 5 min timeout)
|
|
46
|
+
}, (res) => {
|
|
47
|
+
let data = '';
|
|
48
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
49
|
+
res.on('end', () => {
|
|
50
|
+
try {
|
|
51
|
+
resolve({ status: res.statusCode ?? 500, data: JSON.parse(data) });
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
resolve({ status: res.statusCode ?? 500, data });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
req.on('error', reject);
|
|
59
|
+
req.on('timeout', () => {
|
|
60
|
+
req.destroy();
|
|
61
|
+
reject(new Error('Request timeout'));
|
|
62
|
+
});
|
|
63
|
+
req.write(payload);
|
|
64
|
+
req.end();
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function main() {
|
|
68
|
+
const chatId = process.env.CC_BOT_CHAT_ID;
|
|
69
|
+
const port = parseInt(process.env.CC_BOT_HOOK_PORT ?? '18900', 10);
|
|
70
|
+
// No chat ID configured - allow by default and exit
|
|
71
|
+
if (!chatId) {
|
|
72
|
+
// Output allow decision to maintain consistency with hook protocol
|
|
73
|
+
process.stdout.write(JSON.stringify({ permissionDecision: 'allow' }));
|
|
74
|
+
process.exit(HOOK_EXIT_CODES.SUCCESS);
|
|
75
|
+
}
|
|
76
|
+
let input;
|
|
77
|
+
try {
|
|
78
|
+
const raw = await readStdin();
|
|
79
|
+
input = raw.trim() ? JSON.parse(raw) : {};
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
// Cannot parse input - allow by default to avoid blocking legitimate operations
|
|
83
|
+
process.stderr.write(`Warning: Failed to parse hook input, allowing by default: ${err}\n`);
|
|
84
|
+
process.stdout.write(JSON.stringify({ permissionDecision: 'allow' }));
|
|
85
|
+
process.exit(HOOK_EXIT_CODES.SUCCESS);
|
|
86
|
+
}
|
|
87
|
+
const toolName = input.tool_name ?? 'unknown';
|
|
88
|
+
const toolInput = input.tool_input ?? {};
|
|
89
|
+
// Skip permission check for read-only tools - allow immediately
|
|
90
|
+
if (READ_ONLY_TOOLS.includes(toolName)) {
|
|
91
|
+
process.stdout.write(JSON.stringify({ permissionDecision: 'allow' }));
|
|
92
|
+
process.exit(HOOK_EXIT_CODES.SUCCESS);
|
|
93
|
+
}
|
|
94
|
+
const threadRootMsgId = process.env.CC_BOT_THREAD_ROOT_MSG_ID;
|
|
95
|
+
const threadId = process.env.CC_BOT_THREAD_ID;
|
|
96
|
+
const platform = process.env.CC_BOT_PLATFORM;
|
|
97
|
+
try {
|
|
98
|
+
const result = await httpPost(port, '/permission-request', {
|
|
99
|
+
chatId,
|
|
100
|
+
toolName,
|
|
101
|
+
toolInput,
|
|
102
|
+
threadRootMsgId,
|
|
103
|
+
threadId,
|
|
104
|
+
platform,
|
|
105
|
+
});
|
|
106
|
+
const data = result.data;
|
|
107
|
+
const decision = data?.decision ?? 'deny';
|
|
108
|
+
// Output the decision as JSON to stdout
|
|
109
|
+
const output = JSON.stringify({ permissionDecision: decision === 'allow' ? 'allow' : 'deny' });
|
|
110
|
+
process.stdout.write(output);
|
|
111
|
+
process.exit(HOOK_EXIT_CODES.SUCCESS);
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
// Permission server is not reachable - deny by default for security
|
|
115
|
+
// Output deny decision to stdout so Claude Code can proceed (rather than hanging)
|
|
116
|
+
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
117
|
+
process.stderr.write(`Error: Permission server unreachable (port ${port}): ${errorMessage}\n`);
|
|
118
|
+
process.stderr.write('Denying operation by default for security. Please check if cc-im is running.\n');
|
|
119
|
+
// Write deny decision to stdout
|
|
120
|
+
process.stdout.write(JSON.stringify({ permissionDecision: 'deny' }));
|
|
121
|
+
process.exit(HOOK_EXIT_CODES.PERMISSION_SERVER_ERROR);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
main();
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createLogger } from '../logger.js';
|
|
3
|
+
import { PERMISSION_REQUEST_TIMEOUT_MS, MAX_BODY_SIZE } from '../constants.js';
|
|
4
|
+
const log = createLogger('PermissionServer');
|
|
5
|
+
const senders = new Map();
|
|
6
|
+
export function registerPermissionSender(platform, s) {
|
|
7
|
+
senders.set(platform, s);
|
|
8
|
+
}
|
|
9
|
+
const pendingRequests = new Map();
|
|
10
|
+
// 反向索引:chatId → Set<requestId>,O(1) 查询
|
|
11
|
+
const chatIdIndex = new Map();
|
|
12
|
+
function addToIndex(chatId, id) {
|
|
13
|
+
let set = chatIdIndex.get(chatId);
|
|
14
|
+
if (!set) {
|
|
15
|
+
set = new Set();
|
|
16
|
+
chatIdIndex.set(chatId, set);
|
|
17
|
+
}
|
|
18
|
+
set.add(id);
|
|
19
|
+
}
|
|
20
|
+
function removeFromIndex(chatId, id) {
|
|
21
|
+
const set = chatIdIndex.get(chatId);
|
|
22
|
+
if (set) {
|
|
23
|
+
set.delete(id);
|
|
24
|
+
if (set.size === 0)
|
|
25
|
+
chatIdIndex.delete(chatId);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Resolve the latest pending request for a given chatId
|
|
29
|
+
export function resolveLatestPermission(chatId, decision) {
|
|
30
|
+
const ids = chatIdIndex.get(chatId);
|
|
31
|
+
if (!ids || ids.size === 0)
|
|
32
|
+
return null;
|
|
33
|
+
let oldest = null;
|
|
34
|
+
for (const id of ids) {
|
|
35
|
+
const req = pendingRequests.get(id);
|
|
36
|
+
if (req && (!oldest || req.createdAt < oldest.createdAt)) {
|
|
37
|
+
oldest = req;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!oldest)
|
|
41
|
+
return null;
|
|
42
|
+
oldest.resolve(decision);
|
|
43
|
+
const platformSender = senders.get(oldest.platform);
|
|
44
|
+
platformSender?.updatePermissionCard(oldest.chatId, oldest.messageId, oldest.toolName, decision).catch(() => { });
|
|
45
|
+
pendingRequests.delete(oldest.id);
|
|
46
|
+
removeFromIndex(chatId, oldest.id);
|
|
47
|
+
return oldest.id;
|
|
48
|
+
}
|
|
49
|
+
export function getPendingCount(chatId) {
|
|
50
|
+
return chatIdIndex.get(chatId)?.size ?? 0;
|
|
51
|
+
}
|
|
52
|
+
export function listPending(chatId) {
|
|
53
|
+
const ids = chatIdIndex.get(chatId);
|
|
54
|
+
if (!ids)
|
|
55
|
+
return [];
|
|
56
|
+
const result = [];
|
|
57
|
+
for (const id of ids) {
|
|
58
|
+
const req = pendingRequests.get(id);
|
|
59
|
+
if (req)
|
|
60
|
+
result.push(req);
|
|
61
|
+
}
|
|
62
|
+
return result.sort((a, b) => a.createdAt - b.createdAt);
|
|
63
|
+
}
|
|
64
|
+
function readBody(req) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
let body = '';
|
|
67
|
+
let size = 0;
|
|
68
|
+
req.on('data', (chunk) => {
|
|
69
|
+
size += chunk.length;
|
|
70
|
+
if (size > MAX_BODY_SIZE) {
|
|
71
|
+
req.destroy();
|
|
72
|
+
reject(new Error(`Request body too large (>${MAX_BODY_SIZE} bytes)`));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
body += chunk.toString();
|
|
76
|
+
});
|
|
77
|
+
req.on('end', () => resolve(body));
|
|
78
|
+
req.on('error', reject);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function sendJson(res, status, data) {
|
|
82
|
+
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
83
|
+
res.end(JSON.stringify(data));
|
|
84
|
+
}
|
|
85
|
+
async function handleRequest(req, res) {
|
|
86
|
+
const url = new URL(req.url ?? '/', `http://localhost`);
|
|
87
|
+
if (req.method === 'POST' && url.pathname === '/permission-request') {
|
|
88
|
+
try {
|
|
89
|
+
const body = JSON.parse(await readBody(req));
|
|
90
|
+
// 运行时类型验证
|
|
91
|
+
if (typeof body !== 'object' || body === null) {
|
|
92
|
+
sendJson(res, 400, { error: 'Request body must be a JSON object' });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const { chatId, toolName, toolInput, threadRootMsgId, threadId, platform } = body;
|
|
96
|
+
if (typeof chatId !== 'string' || !chatId) {
|
|
97
|
+
sendJson(res, 400, { error: 'chatId must be a non-empty string' });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (typeof toolName !== 'string' || !toolName) {
|
|
101
|
+
sendJson(res, 400, { error: 'toolName must be a non-empty string' });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (toolInput !== undefined && (typeof toolInput !== 'object' || toolInput === null)) {
|
|
105
|
+
sendJson(res, 400, { error: 'toolInput must be an object if provided' });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (platform !== undefined && typeof platform !== 'string') {
|
|
109
|
+
sendJson(res, 400, { error: 'platform must be a string if provided' });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (threadRootMsgId !== undefined && typeof threadRootMsgId !== 'string') {
|
|
113
|
+
sendJson(res, 400, { error: 'threadRootMsgId must be a string if provided' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (threadId !== undefined && typeof threadId !== 'string') {
|
|
117
|
+
sendJson(res, 400, { error: 'threadId must be a string if provided' });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// 构造话题上下文(两个字段都存在才构造,避免空字符串导致 reply API 调用失败)
|
|
121
|
+
const threadCtx = (threadRootMsgId && threadId)
|
|
122
|
+
? { rootMessageId: threadRootMsgId, threadId }
|
|
123
|
+
: undefined;
|
|
124
|
+
const resolvedPlatform = platform ?? 'feishu';
|
|
125
|
+
const platformSender = senders.get(resolvedPlatform);
|
|
126
|
+
const id = `perm_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
127
|
+
let savedMessageId = '';
|
|
128
|
+
const decision = await new Promise((resolve) => {
|
|
129
|
+
if (!platformSender) {
|
|
130
|
+
log.error(`Permission sender not configured for platform: ${resolvedPlatform}`);
|
|
131
|
+
resolve('deny');
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
let resolved = false;
|
|
135
|
+
const safeResolve = (decision) => {
|
|
136
|
+
if (resolved)
|
|
137
|
+
return;
|
|
138
|
+
resolved = true;
|
|
139
|
+
resolve(decision);
|
|
140
|
+
};
|
|
141
|
+
// 超时定时器在发卡片之前启动,确保总等待时间不超过上限
|
|
142
|
+
const timeout = setTimeout(() => {
|
|
143
|
+
if (pendingRequests.has(id)) {
|
|
144
|
+
pendingRequests.delete(id);
|
|
145
|
+
removeFromIndex(chatId, id);
|
|
146
|
+
log.warn(`Permission request ${id} timed out`);
|
|
147
|
+
if (savedMessageId) {
|
|
148
|
+
platformSender.updatePermissionCard(chatId, savedMessageId, toolName, 'deny').catch(() => { });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
safeResolve('deny');
|
|
152
|
+
}, PERMISSION_REQUEST_TIMEOUT_MS);
|
|
153
|
+
platformSender.sendPermissionCard(chatId, id, toolName, toolInput ?? {}, threadCtx).then((messageId) => {
|
|
154
|
+
savedMessageId = messageId;
|
|
155
|
+
const pending = {
|
|
156
|
+
id,
|
|
157
|
+
chatId,
|
|
158
|
+
platform: resolvedPlatform,
|
|
159
|
+
toolName,
|
|
160
|
+
toolInput: toolInput ?? {},
|
|
161
|
+
messageId,
|
|
162
|
+
createdAt: Date.now(),
|
|
163
|
+
resolve: (decision) => {
|
|
164
|
+
clearTimeout(timeout);
|
|
165
|
+
safeResolve(decision);
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
pendingRequests.set(id, pending);
|
|
169
|
+
addToIndex(chatId, id);
|
|
170
|
+
log.info(`Permission request created: ${id} tool=${toolName} platform=${resolvedPlatform}`);
|
|
171
|
+
}).catch((err) => {
|
|
172
|
+
log.error('Failed to send permission card:', err);
|
|
173
|
+
clearTimeout(timeout);
|
|
174
|
+
safeResolve('deny');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
sendJson(res, 200, { id, decision });
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
log.error('Error handling permission request:', err);
|
|
181
|
+
sendJson(res, 500, { error: 'Internal error' });
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
if (req.method === 'GET' && url.pathname === '/health') {
|
|
186
|
+
sendJson(res, 200, { status: 'ok', pending: pendingRequests.size });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
sendJson(res, 404, { error: 'Not found' });
|
|
190
|
+
}
|
|
191
|
+
export function startPermissionServer(port) {
|
|
192
|
+
return new Promise((resolve) => {
|
|
193
|
+
const server = createServer((req, res) => {
|
|
194
|
+
handleRequest(req, res).catch((err) => {
|
|
195
|
+
log.error('Unhandled error in permission server:', err);
|
|
196
|
+
if (!res.headersSent) {
|
|
197
|
+
sendJson(res, 500, { error: 'Internal error' });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
server.listen(port, '127.0.0.1', () => {
|
|
202
|
+
log.info(`Permission server listening on 127.0.0.1:${port}`);
|
|
203
|
+
resolve();
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
}
|