chatccc 0.2.0 → 0.2.1
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/package.json +1 -1
- package/src/feishu-api.ts +128 -0
- package/src/index.ts +30 -10
package/package.json
CHANGED
package/src/feishu-api.ts
CHANGED
|
@@ -32,6 +32,134 @@ export async function getTenantAccessToken(): Promise<string> {
|
|
|
32
32
|
return data.tenant_access_token;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Permission verification (startup check)
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
interface PermissionDef {
|
|
40
|
+
scope: string;
|
|
41
|
+
description: string;
|
|
42
|
+
method: "GET" | "POST" | "PATCH" | "DELETE";
|
|
43
|
+
path: string;
|
|
44
|
+
body?: string; // JSON string, supports __TOKEN__ placeholder
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 项目所需的所有飞书权限及对应的非破坏性测试请求 */
|
|
48
|
+
const REQUIRED_PERMISSIONS: PermissionDef[] = [
|
|
49
|
+
{
|
|
50
|
+
scope: "im:chat",
|
|
51
|
+
description: "读取/创建/更新群聊(创建会话群、改名、读群信息)",
|
|
52
|
+
method: "GET",
|
|
53
|
+
path: "/im/v1/chats?page_size=1",
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
scope: "im:message:send_as_bot",
|
|
57
|
+
description: "以机器人身份发送消息(文本/卡片回复)",
|
|
58
|
+
method: "POST",
|
|
59
|
+
path: "/im/v1/messages?receive_id_type=chat_id",
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
receive_id: "oc_000000000000000000000000",
|
|
62
|
+
msg_type: "text",
|
|
63
|
+
content: JSON.stringify({ text: "permcheck" }),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
scope: "im:message:reaction",
|
|
68
|
+
description: "添加消息表情回应(Give a like)",
|
|
69
|
+
method: "POST",
|
|
70
|
+
path: "/im/v1/messages/om_000000000000000000000000/reactions",
|
|
71
|
+
body: JSON.stringify({ reaction_type: { emoji_type: "Get" } }),
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
scope: "im:message",
|
|
75
|
+
description: "撤回/更新消息(关闭卡片、撤回卡片消息)",
|
|
76
|
+
method: "PATCH",
|
|
77
|
+
path: "/im/v1/messages/om_000000000000000000000000",
|
|
78
|
+
body: JSON.stringify({ content: JSON.stringify({ elements: [{ tag: "markdown", content: " " }] }) }),
|
|
79
|
+
},
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
interface PermissionResult {
|
|
83
|
+
scope: string;
|
|
84
|
+
description: string;
|
|
85
|
+
ok: boolean;
|
|
86
|
+
detail: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 对单个权限做非破坏性探针请求。返回 false 表示权限缺失。 */
|
|
90
|
+
async function probePermission(token: string, def: PermissionDef): Promise<PermissionResult> {
|
|
91
|
+
const url = `${BASE_URL}${def.path}`;
|
|
92
|
+
try {
|
|
93
|
+
const resp = await fetch(url, {
|
|
94
|
+
method: def.method,
|
|
95
|
+
headers: {
|
|
96
|
+
Authorization: `Bearer ${token}`,
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
},
|
|
99
|
+
body: def.body ?? undefined,
|
|
100
|
+
});
|
|
101
|
+
const data = (await resp.json()) as { code: number; msg?: string };
|
|
102
|
+
if (data.code === 0) {
|
|
103
|
+
return { scope: def.scope, description: def.description, ok: true, detail: "通过" };
|
|
104
|
+
}
|
|
105
|
+
const msg = (data.msg ?? "").toLowerCase();
|
|
106
|
+
// 飞书权限缺失时 msg 通常含 "permission" / "scope" / "denied" / "unauthorized"
|
|
107
|
+
// 权限 OK 但资源不存在时 msg 含 "not found" / "chat" / "message" / "不存在"
|
|
108
|
+
const deniedKeywords = ["permission", "scope", "denied", "unauthorized", "无权限", "权限", "access denied"];
|
|
109
|
+
const isDenied = deniedKeywords.some((kw) => msg.includes(kw));
|
|
110
|
+
if (isDenied) {
|
|
111
|
+
return { scope: def.scope, description: def.description, ok: false, detail: `权限缺失 → code=${data.code} msg=${data.msg}` };
|
|
112
|
+
}
|
|
113
|
+
// 资源不存在 = 通过了权限检查,只是目标资源不存在(预期内)
|
|
114
|
+
return { scope: def.scope, description: def.description, ok: true, detail: `通过(资源不存在: code=${data.code} msg=${data.msg})` };
|
|
115
|
+
} catch (err) {
|
|
116
|
+
return { scope: def.scope, description: def.description, ok: false, detail: `网络异常: ${(err as Error).message}` };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 验证所有必需权限。返回失败的权限列表。
|
|
122
|
+
* 若全部通过返回空数组。
|
|
123
|
+
*/
|
|
124
|
+
export async function verifyAllPermissions(token: string): Promise<PermissionResult[]> {
|
|
125
|
+
const results: PermissionResult[] = [];
|
|
126
|
+
for (const def of REQUIRED_PERMISSIONS) {
|
|
127
|
+
const r = await probePermission(token, def);
|
|
128
|
+
results.push(r);
|
|
129
|
+
}
|
|
130
|
+
return results;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** 输出权限验证结果到控制台和文件日志,并返回是否有失败 */
|
|
134
|
+
export function reportPermissionResults(
|
|
135
|
+
results: PermissionResult[],
|
|
136
|
+
log: (msg: string) => void
|
|
137
|
+
): boolean {
|
|
138
|
+
const failed = results.filter((r) => !r.ok);
|
|
139
|
+
const passed = results.filter((r) => r.ok);
|
|
140
|
+
|
|
141
|
+
log(`\n${"-".repeat(60)}`);
|
|
142
|
+
log(`[权限验证] 飞书应用权限检查完成: ${passed.length}/${results.length} 通过`);
|
|
143
|
+
for (const r of passed) {
|
|
144
|
+
log(` [通过] ${r.scope} — ${r.description}`);
|
|
145
|
+
}
|
|
146
|
+
for (const r of failed) {
|
|
147
|
+
log(` [失败] ${r.scope} — ${r.description}`);
|
|
148
|
+
log(` 原因: ${r.detail}`);
|
|
149
|
+
}
|
|
150
|
+
if (failed.length > 0) {
|
|
151
|
+
log(`\n[权限验证] 以下权限未在飞书开放平台中配置:`);
|
|
152
|
+
for (const r of failed) {
|
|
153
|
+
log(` - ${r.scope}: ${r.description}`);
|
|
154
|
+
}
|
|
155
|
+
log(`\n请到飞书开放平台 → 应用详情 → 权限管理 中开通上述权限后重试。`);
|
|
156
|
+
log(`${"-".repeat(60)}\n`);
|
|
157
|
+
} else {
|
|
158
|
+
log(`${"-".repeat(60)}\n`);
|
|
159
|
+
}
|
|
160
|
+
return failed.length > 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
35
163
|
// ---------------------------------------------------------------------------
|
|
36
164
|
// Group chat CRUD
|
|
37
165
|
// ---------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -63,6 +63,8 @@ import {
|
|
|
63
63
|
updateCardMessage,
|
|
64
64
|
updateChatInfo,
|
|
65
65
|
sendRestartCard,
|
|
66
|
+
verifyAllPermissions,
|
|
67
|
+
reportPermissionResults,
|
|
66
68
|
} from "./feishu-api.ts";
|
|
67
69
|
import { buildHelpCard, buildStatusCard, buildThinkingCardV2, buildCdContent, buildSessionsCard } from "./cards.ts";
|
|
68
70
|
import { updateCardKitCard } from "./cardkit.ts";
|
|
@@ -281,9 +283,10 @@ async function handleCommand(text: string, chatId: string, openId: string, msgTi
|
|
|
281
283
|
return;
|
|
282
284
|
}
|
|
283
285
|
|
|
286
|
+
const cwd = await getDefaultCwd();
|
|
284
287
|
await sendCardReply(
|
|
285
288
|
freshToken, newChatId, "Claude Session Ready",
|
|
286
|
-
`群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n\n直接在这里发消息即可与 Claude
|
|
289
|
+
`群聊已创建,这是你的 Claude 会话群。\n\n**Session ID:** ${sessionId}\n**工作目录:** \`${cwd}\`\n\n直接在这里发消息即可与 Claude 对话。\n\n发送 **/sessions** 查看所有会话状态。`,
|
|
287
290
|
"green"
|
|
288
291
|
);
|
|
289
292
|
|
|
@@ -464,14 +467,14 @@ async function main(): Promise<void> {
|
|
|
464
467
|
process.exit(1);
|
|
465
468
|
}
|
|
466
469
|
|
|
467
|
-
console.log(`\n[启动 1/
|
|
470
|
+
console.log(`\n[启动 1/7] 单实例:按 PID 文件清理旧 ChatCCC 进程`);
|
|
468
471
|
console.log(` PID 文件: ${PID_FILE}`);
|
|
469
472
|
appendStartupTrace("main: before ensureSingleInstance", { PID_FILE, CHATCCC_PORT });
|
|
470
473
|
ensureSingleInstance(PID_FILE);
|
|
471
474
|
appendStartupTrace("main: after ensureSingleInstance");
|
|
472
475
|
console.log(" 完成。\n");
|
|
473
476
|
|
|
474
|
-
console.log(`[启动 2/
|
|
477
|
+
console.log(`[启动 2/7] 环境与凭证检查`);
|
|
475
478
|
reportEnvironmentVariableReadout();
|
|
476
479
|
console.log(` 工作目录: ${process.cwd()}`);
|
|
477
480
|
console.log(` 包根目录: ${PROJECT_ROOT}`);
|
|
@@ -485,7 +488,7 @@ async function main(): Promise<void> {
|
|
|
485
488
|
console.log(` 必填项校验通过(App ID 摘要: ${maskAppId(APP_ID)})。\n`);
|
|
486
489
|
appendStartupTrace("main: feishu credentials ok", { appIdMask: maskAppId(APP_ID) });
|
|
487
490
|
|
|
488
|
-
console.log(`[启动 3/
|
|
491
|
+
console.log(`[启动 3/7] 启动本地 WebSocket 中继(ws://127.0.0.1:${CHATCCC_PORT})…`);
|
|
489
492
|
appendStartupTrace("main: before freeRelayListenPort", { CHATCCC_PORT });
|
|
490
493
|
freeRelayListenPort(CHATCCC_PORT);
|
|
491
494
|
appendStartupTrace("main: after freeRelayListenPort", { CHATCCC_PORT });
|
|
@@ -501,7 +504,7 @@ async function main(): Promise<void> {
|
|
|
501
504
|
console.log(` In a Claude session group, send any message to resume & prompt.`);
|
|
502
505
|
console.log(`${"=".repeat(60)}`);
|
|
503
506
|
|
|
504
|
-
console.log(`\n[启动 4/
|
|
507
|
+
console.log(`\n[启动 4/7] 向飞书开放平台申请 tenant_access_token …`);
|
|
505
508
|
let token: string;
|
|
506
509
|
try {
|
|
507
510
|
token = await getTenantAccessToken();
|
|
@@ -519,6 +522,23 @@ async function main(): Promise<void> {
|
|
|
519
522
|
}
|
|
520
523
|
console.log(` 完成。当前 App ID 摘要: ${maskAppId(APP_ID)}\n`);
|
|
521
524
|
|
|
525
|
+
console.log(`\n[启动 5/7] 验证飞书应用权限(非破坏性探针)…`);
|
|
526
|
+
const permResults = await verifyAllPermissions(token);
|
|
527
|
+
const hasFailed = reportPermissionResults(permResults, (msg) => {
|
|
528
|
+
console.log(msg);
|
|
529
|
+
});
|
|
530
|
+
if (hasFailed) {
|
|
531
|
+
appendStartupTrace("main: permissions check failed", {
|
|
532
|
+
failed: permResults.filter((r) => !r.ok).map((r) => r.scope).join(", "),
|
|
533
|
+
});
|
|
534
|
+
printServiceDidNotStart(
|
|
535
|
+
`飞书权限不足: ${permResults.filter((r) => !r.ok).map((r) => r.scope).join(", ")}`
|
|
536
|
+
);
|
|
537
|
+
process.exit(1);
|
|
538
|
+
}
|
|
539
|
+
appendStartupTrace("main: permissions check ok");
|
|
540
|
+
console.log(` 完成。所有必需权限已验证通过。\n`);
|
|
541
|
+
|
|
522
542
|
console.log(`[${ts()}] [AUTH] Token obtained`);
|
|
523
543
|
|
|
524
544
|
const eventDispatcher = new EventDispatcher({});
|
|
@@ -613,14 +633,14 @@ async function main(): Promise<void> {
|
|
|
613
633
|
});
|
|
614
634
|
|
|
615
635
|
if (USE_LOCAL) {
|
|
616
|
-
console.log(`\n[启动
|
|
636
|
+
console.log(`\n[启动 6/7] 本地区 relay 模式:正在连接 ${LOCAL_RELAY_URL} …`);
|
|
617
637
|
console.log(" 若失败:请先在 SDK 模式下启动主进程,或确认本机中继已在该地址监听。");
|
|
618
638
|
let localRelayOpened = false;
|
|
619
639
|
const ws = new WebSocket(LOCAL_RELAY_URL);
|
|
620
640
|
ws.on("open", () => {
|
|
621
641
|
localRelayOpened = true;
|
|
622
642
|
console.log("[WS] Connected to local relay");
|
|
623
|
-
console.log("[启动
|
|
643
|
+
console.log("[启动 7/7] 已连接本地中继,可接收转发事件。\n");
|
|
624
644
|
printServiceRunningHint("local");
|
|
625
645
|
});
|
|
626
646
|
ws.on("message", (raw: Buffer) => {
|
|
@@ -648,7 +668,7 @@ async function main(): Promise<void> {
|
|
|
648
668
|
ws.on("close", () => { console.log("[WS] Local relay disconnected"); process.exit(0); });
|
|
649
669
|
ws.on("error", (err: Error) => {
|
|
650
670
|
if (!localRelayOpened) {
|
|
651
|
-
console.error(`[启动
|
|
671
|
+
console.error(`[启动 6/7] 失败:无法连接本地中继。`);
|
|
652
672
|
console.error(` ${err.message}`);
|
|
653
673
|
console.error(` 目标: ${LOCAL_RELAY_URL}`);
|
|
654
674
|
printServiceDidNotStart(`无法连接本地中继 ${LOCAL_RELAY_URL}`);
|
|
@@ -666,7 +686,7 @@ async function main(): Promise<void> {
|
|
|
666
686
|
onReconnected: () => resetState(),
|
|
667
687
|
});
|
|
668
688
|
|
|
669
|
-
console.log(`\n[启动
|
|
689
|
+
console.log(`\n[启动 6/7] 飞书长连接:正在通过 SDK 建立 WebSocket …`);
|
|
670
690
|
try {
|
|
671
691
|
await wsClient.start({ eventDispatcher });
|
|
672
692
|
} catch (err) {
|
|
@@ -678,7 +698,7 @@ async function main(): Promise<void> {
|
|
|
678
698
|
process.exit(1);
|
|
679
699
|
}
|
|
680
700
|
console.log("[WS] Feishu WebSocket connected (SDK)");
|
|
681
|
-
console.log("[启动
|
|
701
|
+
console.log("[启动 7/7] 服务已就绪,等待飞书消息(群聊 / 卡片回调)。\n");
|
|
682
702
|
printServiceRunningHint("sdk");
|
|
683
703
|
|
|
684
704
|
sendRestartCard(token).catch((err) =>
|