pi-feishu-cli 0.1.0 → 0.2.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/.github/workflows/publish.yml +25 -0
- package/package.json +1 -1
- package/dist/src/bot.d.ts +0 -29
- package/dist/src/bot.js +0 -75
- package/dist/src/cards.d.ts +0 -6
- package/dist/src/cards.js +0 -87
- package/dist/src/config.d.ts +0 -3
- package/dist/src/config.js +0 -28
- package/dist/src/daemon.d.ts +0 -2
- package/dist/src/daemon.js +0 -151
- package/dist/src/extension.d.ts +0 -2
- package/dist/src/extension.js +0 -124
- package/dist/src/poller.d.ts +0 -33
- package/dist/src/poller.js +0 -94
- package/dist/src/renderer.d.ts +0 -8
- package/dist/src/renderer.js +0 -31
- package/dist/src/session-registry.d.ts +0 -15
- package/dist/src/session-registry.js +0 -82
- package/dist/src/types.d.ts +0 -25
- package/dist/src/types.js +0 -1
- package/dist/tests/bot.test.d.ts +0 -1
- package/dist/tests/bot.test.js +0 -89
- package/dist/tests/cards.test.d.ts +0 -1
- package/dist/tests/cards.test.js +0 -39
- package/dist/tests/config.test.d.ts +0 -1
- package/dist/tests/config.test.js +0 -59
- package/dist/tests/renderer.test.d.ts +0 -1
- package/dist/tests/renderer.test.js +0 -61
- package/dist/tests/session-registry.test.d.ts +0 -1
- package/dist/tests/session-registry.test.js +0 -92
- package/dist/tests/types.test.d.ts +0 -1
- package/dist/tests/types.test.js +0 -30
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
id-token: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
publish:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v5
|
|
16
|
+
- uses: actions/setup-node@v5
|
|
17
|
+
with:
|
|
18
|
+
node-version: 24
|
|
19
|
+
registry-url: https://registry.npmjs.org
|
|
20
|
+
- name: Install devDependencies
|
|
21
|
+
run: npm install
|
|
22
|
+
- name: Run tests
|
|
23
|
+
run: npm run test
|
|
24
|
+
- name: Publish to npm
|
|
25
|
+
run: npm publish --provenance
|
package/package.json
CHANGED
package/dist/src/bot.d.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { SessionRegistry } from "./session-registry.js";
|
|
2
|
-
import type { FeishuEvent } from "./poller.js";
|
|
3
|
-
import type { FeishuImConfig } from "./types.js";
|
|
4
|
-
export interface RouteResultCommand {
|
|
5
|
-
type: "command";
|
|
6
|
-
command: string;
|
|
7
|
-
args: string;
|
|
8
|
-
chatId: string;
|
|
9
|
-
threadId?: string;
|
|
10
|
-
}
|
|
11
|
-
export interface RouteResultMessage {
|
|
12
|
-
type: "message";
|
|
13
|
-
text: string;
|
|
14
|
-
chatId: string;
|
|
15
|
-
threadId?: string;
|
|
16
|
-
}
|
|
17
|
-
export interface RouteResultSkip {
|
|
18
|
-
type: "skip";
|
|
19
|
-
}
|
|
20
|
-
export type RouteResult = RouteResultCommand | RouteResultMessage | RouteResultSkip;
|
|
21
|
-
export declare class Bot {
|
|
22
|
-
private registry;
|
|
23
|
-
private strategy;
|
|
24
|
-
constructor(registry: SessionRegistry, strategy: FeishuImConfig["strategy"]);
|
|
25
|
-
route(event: FeishuEvent): RouteResult;
|
|
26
|
-
private isCommand;
|
|
27
|
-
private parseCommand;
|
|
28
|
-
private extractText;
|
|
29
|
-
}
|
package/dist/src/bot.js
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
const BOT_OPEN_ID = "__bot_open_id__";
|
|
2
|
-
export class Bot {
|
|
3
|
-
registry;
|
|
4
|
-
strategy;
|
|
5
|
-
constructor(registry, strategy) {
|
|
6
|
-
this.registry = registry;
|
|
7
|
-
this.strategy = strategy;
|
|
8
|
-
}
|
|
9
|
-
route(event) {
|
|
10
|
-
const msg = event.event?.message;
|
|
11
|
-
if (!msg)
|
|
12
|
-
return { type: "skip" };
|
|
13
|
-
const chatId = msg.chat_id;
|
|
14
|
-
const text = this.extractText(msg.content, msg.message_type);
|
|
15
|
-
if (!text)
|
|
16
|
-
return { type: "skip" };
|
|
17
|
-
const mentions = msg.mentions ?? [];
|
|
18
|
-
const hasMentions = mentions.length > 0;
|
|
19
|
-
const isMentioned = mentions.some((m) => m.key === BOT_OPEN_ID);
|
|
20
|
-
if (this.strategy === "mention" && hasMentions && !isMentioned) {
|
|
21
|
-
if (!this.isCommand(text))
|
|
22
|
-
return { type: "skip" };
|
|
23
|
-
}
|
|
24
|
-
const commandResult = this.parseCommand(text);
|
|
25
|
-
if (commandResult) {
|
|
26
|
-
return {
|
|
27
|
-
type: "command",
|
|
28
|
-
command: commandResult.command,
|
|
29
|
-
args: commandResult.args,
|
|
30
|
-
chatId,
|
|
31
|
-
threadId: msg.parent_id,
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
return {
|
|
35
|
-
type: "message",
|
|
36
|
-
text,
|
|
37
|
-
chatId,
|
|
38
|
-
threadId: msg.parent_id,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
isCommand(text) {
|
|
42
|
-
return ["/new", "/sessions", "/switch", "/rm", "/model"].some((cmd) => text.trim().startsWith(cmd));
|
|
43
|
-
}
|
|
44
|
-
parseCommand(text) {
|
|
45
|
-
const trimmed = text.trim();
|
|
46
|
-
if (trimmed === "/sessions" || trimmed === "/model") {
|
|
47
|
-
return { command: trimmed.slice(1), args: "" };
|
|
48
|
-
}
|
|
49
|
-
if (trimmed.startsWith("/new ")) {
|
|
50
|
-
return { command: "new", args: trimmed.slice(5).trim() || "默认会话" };
|
|
51
|
-
}
|
|
52
|
-
if (trimmed === "/new") {
|
|
53
|
-
return { command: "new", args: "默认会话" };
|
|
54
|
-
}
|
|
55
|
-
if (trimmed.startsWith("/switch ")) {
|
|
56
|
-
return { command: "switch", args: trimmed.slice(8).trim() };
|
|
57
|
-
}
|
|
58
|
-
if (trimmed.startsWith("/rm ")) {
|
|
59
|
-
return { command: "rm", args: trimmed.slice(4).trim() };
|
|
60
|
-
}
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
extractText(content, msgType) {
|
|
64
|
-
if (msgType === "text") {
|
|
65
|
-
try {
|
|
66
|
-
const parsed = JSON.parse(content);
|
|
67
|
-
return parsed.text ?? "";
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
return content;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
return "";
|
|
74
|
-
}
|
|
75
|
-
}
|
package/dist/src/cards.d.ts
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
import type { SessionInfo } from "./types.js";
|
|
2
|
-
export declare function buildSessionListCard(chatId: string, sessions: SessionInfo[], activeId: string | null): string;
|
|
3
|
-
export declare function buildModelSelectCard(chatId: string, models: Array<{
|
|
4
|
-
id: string;
|
|
5
|
-
name: string;
|
|
6
|
-
}>, current: string): string;
|
package/dist/src/cards.js
DELETED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
export function buildSessionListCard(chatId, sessions, activeId) {
|
|
2
|
-
const header = {
|
|
3
|
-
title: { tag: "plain_text", content: "Pi 会话管理" },
|
|
4
|
-
template: "blue",
|
|
5
|
-
};
|
|
6
|
-
const elements = [];
|
|
7
|
-
if (sessions.length === 0) {
|
|
8
|
-
elements.push({
|
|
9
|
-
tag: "div",
|
|
10
|
-
text: { tag: "lark_md", content: "暂无会话" },
|
|
11
|
-
});
|
|
12
|
-
}
|
|
13
|
-
else {
|
|
14
|
-
for (const sess of sessions) {
|
|
15
|
-
const isActive = sess.id === activeId;
|
|
16
|
-
const prefix = isActive ? "▶ " : "";
|
|
17
|
-
elements.push({
|
|
18
|
-
tag: "div",
|
|
19
|
-
text: {
|
|
20
|
-
tag: "lark_md",
|
|
21
|
-
content: `${prefix}**${sess.name}** \n\`${sess.id}\``,
|
|
22
|
-
},
|
|
23
|
-
});
|
|
24
|
-
elements.push({ tag: "hr" });
|
|
25
|
-
}
|
|
26
|
-
elements.pop(); // remove last hr
|
|
27
|
-
}
|
|
28
|
-
elements.push({
|
|
29
|
-
tag: "action",
|
|
30
|
-
actions: [
|
|
31
|
-
{
|
|
32
|
-
tag: "button",
|
|
33
|
-
text: { tag: "plain_text", content: "➕ 新建会话" },
|
|
34
|
-
type: "primary",
|
|
35
|
-
value: JSON.stringify({ action: "new_session", chat_id: chatId }),
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
tag: "button",
|
|
39
|
-
text: { tag: "plain_text", content: "🔄 切换模型" },
|
|
40
|
-
value: JSON.stringify({ action: "model_select", chat_id: chatId }),
|
|
41
|
-
},
|
|
42
|
-
],
|
|
43
|
-
});
|
|
44
|
-
return JSON.stringify({
|
|
45
|
-
msg_type: "interactive",
|
|
46
|
-
card: { header, elements },
|
|
47
|
-
});
|
|
48
|
-
}
|
|
49
|
-
export function buildModelSelectCard(chatId, models, current) {
|
|
50
|
-
const header = {
|
|
51
|
-
title: { tag: "plain_text", content: "选择模型" },
|
|
52
|
-
template: "blue",
|
|
53
|
-
};
|
|
54
|
-
const elements = [];
|
|
55
|
-
elements.push({
|
|
56
|
-
tag: "div",
|
|
57
|
-
text: {
|
|
58
|
-
tag: "lark_md",
|
|
59
|
-
content: `当前: **${models.find((m) => m.id === current)?.name ?? current}**`,
|
|
60
|
-
},
|
|
61
|
-
});
|
|
62
|
-
elements.push({ tag: "hr" });
|
|
63
|
-
for (const model of models) {
|
|
64
|
-
elements.push({
|
|
65
|
-
tag: "action",
|
|
66
|
-
actions: [
|
|
67
|
-
{
|
|
68
|
-
tag: "button",
|
|
69
|
-
text: {
|
|
70
|
-
tag: "plain_text",
|
|
71
|
-
content: model.id === current ? `▶ ${model.name}` : model.name,
|
|
72
|
-
},
|
|
73
|
-
type: model.id === current ? "primary" : "default",
|
|
74
|
-
value: JSON.stringify({
|
|
75
|
-
action: "select_model",
|
|
76
|
-
chat_id: chatId,
|
|
77
|
-
model_id: model.id,
|
|
78
|
-
}),
|
|
79
|
-
},
|
|
80
|
-
],
|
|
81
|
-
});
|
|
82
|
-
}
|
|
83
|
-
return JSON.stringify({
|
|
84
|
-
msg_type: "interactive",
|
|
85
|
-
card: { header, elements },
|
|
86
|
-
});
|
|
87
|
-
}
|
package/dist/src/config.d.ts
DELETED
package/dist/src/config.js
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
export const DEFAULT_CONFIG = {
|
|
4
|
-
strategy: "mention",
|
|
5
|
-
pollInterval: 5,
|
|
6
|
-
autoStart: false,
|
|
7
|
-
};
|
|
8
|
-
export function loadConfig(configDir) {
|
|
9
|
-
if (!existsSync(configDir)) {
|
|
10
|
-
mkdirSync(configDir, { recursive: true });
|
|
11
|
-
}
|
|
12
|
-
const configPath = join(configDir, "config.json");
|
|
13
|
-
if (!existsSync(configPath)) {
|
|
14
|
-
return { ...DEFAULT_CONFIG };
|
|
15
|
-
}
|
|
16
|
-
try {
|
|
17
|
-
const raw = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
18
|
-
return {
|
|
19
|
-
strategy: raw.strategy ?? DEFAULT_CONFIG.strategy,
|
|
20
|
-
model: raw.model,
|
|
21
|
-
pollInterval: raw.pollInterval ?? DEFAULT_CONFIG.pollInterval,
|
|
22
|
-
autoStart: raw.autoStart ?? DEFAULT_CONFIG.autoStart,
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
catch {
|
|
26
|
-
return { ...DEFAULT_CONFIG };
|
|
27
|
-
}
|
|
28
|
-
}
|
package/dist/src/daemon.d.ts
DELETED
package/dist/src/daemon.js
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
|
-
import { writeFileSync } from "node:fs";
|
|
5
|
-
import { createAgentSessionRuntime, createAgentSessionServices, createAgentSessionFromServices, getAgentDir, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { loadConfig } from "./config.js";
|
|
7
|
-
import { SessionRegistry } from "./session-registry.js";
|
|
8
|
-
import { Bot } from "./bot.js";
|
|
9
|
-
import { pollEvents, sendMessage, larkCliAvailable, larkCliConfigured } from "./poller.js";
|
|
10
|
-
import { buildSessionListCard, buildModelSelectCard } from "./cards.js";
|
|
11
|
-
const FEISHU_IM_DIR = join(homedir(), ".pi", "agent", "feishu-im");
|
|
12
|
-
const PID_FILE = join(FEISHU_IM_DIR, "daemon.pid");
|
|
13
|
-
function getAvailableModels() {
|
|
14
|
-
return [
|
|
15
|
-
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
|
|
16
|
-
{ id: "anthropic/claude-sonnet-4-20250514", name: "Claude Sonnet 4" },
|
|
17
|
-
{ id: "anthropic/claude-haiku-3-5", name: "Claude Haiku 3.5" },
|
|
18
|
-
];
|
|
19
|
-
}
|
|
20
|
-
async function handleCommand(registry, command, args, chatId, currentModel) {
|
|
21
|
-
switch (command) {
|
|
22
|
-
case "new": {
|
|
23
|
-
const session = registry.createSession(chatId, args || "未命名会话");
|
|
24
|
-
await sendMessage(JSON.stringify({ text: `已创建会话: **${session.name}** (\`${session.id}\`)` }), chatId);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
case "sessions": {
|
|
28
|
-
const chat = registry.getChatSessions(chatId);
|
|
29
|
-
if (!chat) {
|
|
30
|
-
await sendMessage(JSON.stringify({ text: "暂无会话" }), chatId);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
const card = buildSessionListCard(chatId, chat.sessions, chat.active);
|
|
34
|
-
await sendMessage(card, chatId, "interactive");
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
case "switch": {
|
|
38
|
-
const switched = registry.switchSession(chatId, args);
|
|
39
|
-
if (switched) {
|
|
40
|
-
const session = registry
|
|
41
|
-
.getChatSessions(chatId)
|
|
42
|
-
?.sessions.find((s) => s.id === args);
|
|
43
|
-
await sendMessage(JSON.stringify({ text: `已切换到: **${session?.name ?? args}**` }), chatId);
|
|
44
|
-
}
|
|
45
|
-
else {
|
|
46
|
-
await sendMessage(JSON.stringify({ text: `未找到会话: \`${args}\`` }), chatId);
|
|
47
|
-
}
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
case "rm": {
|
|
51
|
-
const deleted = registry.deleteSession(chatId, args);
|
|
52
|
-
if (deleted) {
|
|
53
|
-
await sendMessage(JSON.stringify({ text: `已删除会话: \`${args}\`` }), chatId);
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
await sendMessage(JSON.stringify({ text: `删除失败,未找到会话: \`${args}\`` }), chatId);
|
|
57
|
-
}
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
case "model": {
|
|
61
|
-
const models = getAvailableModels();
|
|
62
|
-
const card = buildModelSelectCard(chatId, models, currentModel);
|
|
63
|
-
await sendMessage(card, chatId, "interactive");
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
async function runDaemon() {
|
|
69
|
-
if (!(await larkCliAvailable())) {
|
|
70
|
-
console.error("lark-cli 未安装。运行: npm i -g lark-cli");
|
|
71
|
-
process.exit(1);
|
|
72
|
-
}
|
|
73
|
-
if (!(await larkCliConfigured())) {
|
|
74
|
-
console.error("lark-cli 未配置。运行: lark-cli config init");
|
|
75
|
-
process.exit(1);
|
|
76
|
-
}
|
|
77
|
-
const config = loadConfig(FEISHU_IM_DIR);
|
|
78
|
-
const registry = new SessionRegistry(FEISHU_IM_DIR);
|
|
79
|
-
const bot = new Bot(registry, config.strategy);
|
|
80
|
-
writeFileSync(PID_FILE, String(process.pid));
|
|
81
|
-
const cwd = process.cwd();
|
|
82
|
-
const agentDir = getAgentDir();
|
|
83
|
-
const runtime = await createAgentSessionRuntime(async ({ cwd, sessionManager, sessionStartEvent }) => {
|
|
84
|
-
const services = await createAgentSessionServices({ cwd });
|
|
85
|
-
return {
|
|
86
|
-
...(await createAgentSessionFromServices({
|
|
87
|
-
services,
|
|
88
|
-
sessionManager,
|
|
89
|
-
sessionStartEvent,
|
|
90
|
-
})),
|
|
91
|
-
services,
|
|
92
|
-
diagnostics: services.diagnostics,
|
|
93
|
-
};
|
|
94
|
-
}, {
|
|
95
|
-
cwd,
|
|
96
|
-
agentDir,
|
|
97
|
-
sessionManager: SessionManager.create(cwd),
|
|
98
|
-
});
|
|
99
|
-
console.log("[feishu-im] Daemon started, PID:", process.pid);
|
|
100
|
-
console.log("[feishu-im] Strategy:", config.strategy);
|
|
101
|
-
const pollIntervalMs = config.pollInterval * 1000;
|
|
102
|
-
while (true) {
|
|
103
|
-
try {
|
|
104
|
-
const result = await pollEvents();
|
|
105
|
-
if (result.error) {
|
|
106
|
-
console.error("[feishu-im] Poll error:", result.error);
|
|
107
|
-
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
for (const event of result.events) {
|
|
111
|
-
const route = bot.route(event);
|
|
112
|
-
if (route.type === "skip")
|
|
113
|
-
continue;
|
|
114
|
-
if (route.type === "command") {
|
|
115
|
-
await handleCommand(registry, route.command, route.args, route.chatId, config.model ?? "claude-sonnet");
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
const sessionInfo = registry.ensureSession(route.chatId);
|
|
119
|
-
const sessionPath = join(agentDir, "sessions", `${sessionInfo.id}.jsonl`);
|
|
120
|
-
try {
|
|
121
|
-
const sessionManager = SessionManager.open(sessionPath);
|
|
122
|
-
const { session: agentSession } = await createAgentSessionFromServices({
|
|
123
|
-
services: runtime.services,
|
|
124
|
-
sessionManager,
|
|
125
|
-
sessionStartEvent: undefined,
|
|
126
|
-
});
|
|
127
|
-
agentSession.subscribe((agentEvent) => {
|
|
128
|
-
if (agentEvent.type === "message_update" &&
|
|
129
|
-
agentEvent.assistantMessageEvent.type === "text_delta") {
|
|
130
|
-
// Streaming response handling — placeholder for future enhancement
|
|
131
|
-
}
|
|
132
|
-
});
|
|
133
|
-
await agentSession.prompt(route.text);
|
|
134
|
-
agentSession.dispose();
|
|
135
|
-
}
|
|
136
|
-
catch (err) {
|
|
137
|
-
console.error("[feishu-im] Agent error:", err instanceof Error ? err.message : String(err));
|
|
138
|
-
await sendMessage(JSON.stringify({ text: "处理消息时出错,请重试。" }), route.chatId);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
catch (err) {
|
|
143
|
-
console.error("[feishu-im] Loop error:", err);
|
|
144
|
-
}
|
|
145
|
-
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
runDaemon().catch((err) => {
|
|
149
|
-
console.error("[feishu-im] Fatal error:", err);
|
|
150
|
-
process.exit(1);
|
|
151
|
-
});
|
package/dist/src/extension.d.ts
DELETED
package/dist/src/extension.js
DELETED
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { spawn, execSync } from "node:child_process";
|
|
2
|
-
import { readFileSync, unlinkSync } from "node:fs";
|
|
3
|
-
import { join, dirname } from "node:path";
|
|
4
|
-
import { homedir } from "node:os";
|
|
5
|
-
import { fileURLToPath } from "node:url";
|
|
6
|
-
const FEISHU_IM_DIR = join(homedir(), ".pi", "agent", "feishu-im");
|
|
7
|
-
const PID_FILE = join(FEISHU_IM_DIR, "daemon.pid");
|
|
8
|
-
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
-
const __dirname = dirname(__filename);
|
|
10
|
-
function isRunning() {
|
|
11
|
-
try {
|
|
12
|
-
const pid = parseInt(readFileSync(PID_FILE, "utf-8").trim());
|
|
13
|
-
try {
|
|
14
|
-
process.kill(pid, 0);
|
|
15
|
-
return true;
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
catch {
|
|
22
|
-
return false;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
function getPid() {
|
|
26
|
-
try {
|
|
27
|
-
return parseInt(readFileSync(PID_FILE, "utf-8").trim());
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
async function handleStart(ctx) {
|
|
34
|
-
if (isRunning()) {
|
|
35
|
-
ctx.ui.notify(`飞书 IM 守护进程已在运行 (PID: ${getPid()})`, "info");
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
execSync("which lark-cli", { stdio: "ignore" });
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
ctx.ui.notify("lark-cli 未安装。请运行: npm i -g lark-cli", "error");
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
try {
|
|
46
|
-
execSync("lark-cli config show", { stdio: "pipe", timeout: 5000 });
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
ctx.ui.notify("lark-cli 未配置。请运行: lark-cli config init", "error");
|
|
50
|
-
return;
|
|
51
|
-
}
|
|
52
|
-
const daemonPath = join(__dirname, "daemon.ts");
|
|
53
|
-
const child = spawn("node", ["--import", "jiti/register", daemonPath], {
|
|
54
|
-
detached: true,
|
|
55
|
-
stdio: "ignore",
|
|
56
|
-
env: { ...process.env, PI_FEISHU_IM: "1" },
|
|
57
|
-
});
|
|
58
|
-
child.unref();
|
|
59
|
-
await new Promise((r) => setTimeout(r, 2000));
|
|
60
|
-
if (isRunning()) {
|
|
61
|
-
ctx.ui.notify(`飞书 IM 守护进程已启动 (PID: ${getPid()})`, "info");
|
|
62
|
-
}
|
|
63
|
-
else {
|
|
64
|
-
ctx.ui.notify("飞书 IM 守护进程启动失败,请检查日志", "error");
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
function handleStop(ctx) {
|
|
68
|
-
const pid = getPid();
|
|
69
|
-
if (!pid || !isRunning()) {
|
|
70
|
-
ctx.ui.notify("飞书 IM 守护进程未在运行", "info");
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
try {
|
|
74
|
-
process.kill(pid, "SIGTERM");
|
|
75
|
-
try {
|
|
76
|
-
unlinkSync(PID_FILE);
|
|
77
|
-
}
|
|
78
|
-
catch { }
|
|
79
|
-
ctx.ui.notify("飞书 IM 守护进程已停止", "info");
|
|
80
|
-
}
|
|
81
|
-
catch {
|
|
82
|
-
ctx.ui.notify("停止守护进程失败", "error");
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
function handleStatus(ctx) {
|
|
86
|
-
if (isRunning()) {
|
|
87
|
-
ctx.ui.notify(`飞书 IM 守护进程运行中 (PID: ${getPid()})`, "info");
|
|
88
|
-
}
|
|
89
|
-
else {
|
|
90
|
-
ctx.ui.notify("飞书 IM 守护进程未在运行", "info");
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
export default function (pi) {
|
|
94
|
-
pi.registerCommand("feishu-im", {
|
|
95
|
-
description: "管理飞书 IM 守护进程 (start|stop|status|restart)",
|
|
96
|
-
handler: async (args, ctx) => {
|
|
97
|
-
const sub = args?.trim() || "start";
|
|
98
|
-
switch (sub) {
|
|
99
|
-
case "start":
|
|
100
|
-
await handleStart(ctx);
|
|
101
|
-
break;
|
|
102
|
-
case "stop":
|
|
103
|
-
handleStop(ctx);
|
|
104
|
-
break;
|
|
105
|
-
case "status":
|
|
106
|
-
handleStatus(ctx);
|
|
107
|
-
break;
|
|
108
|
-
case "restart":
|
|
109
|
-
handleStop(ctx);
|
|
110
|
-
await new Promise((r) => setTimeout(r, 1000));
|
|
111
|
-
await handleStart(ctx);
|
|
112
|
-
break;
|
|
113
|
-
default:
|
|
114
|
-
ctx.ui.notify("用法: /feishu-im [start|stop|status|restart]", "error");
|
|
115
|
-
}
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
-
pi.registerFlag("feishu-im", {
|
|
119
|
-
description: "启动时自动启动飞书 IM 守护进程",
|
|
120
|
-
handler: async (ctx) => {
|
|
121
|
-
await handleStart(ctx);
|
|
122
|
-
},
|
|
123
|
-
});
|
|
124
|
-
}
|
package/dist/src/poller.d.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
export interface FeishuEvent {
|
|
2
|
-
type: string;
|
|
3
|
-
event?: {
|
|
4
|
-
message?: {
|
|
5
|
-
chat_id: string;
|
|
6
|
-
message_id: string;
|
|
7
|
-
parent_id?: string;
|
|
8
|
-
message_type: string;
|
|
9
|
-
content: string;
|
|
10
|
-
mentions?: Array<{
|
|
11
|
-
key: string;
|
|
12
|
-
name: string;
|
|
13
|
-
}>;
|
|
14
|
-
};
|
|
15
|
-
sender?: {
|
|
16
|
-
sender_id: {
|
|
17
|
-
open_id: string;
|
|
18
|
-
user_id?: string;
|
|
19
|
-
};
|
|
20
|
-
sender_type: string;
|
|
21
|
-
};
|
|
22
|
-
};
|
|
23
|
-
raw: unknown;
|
|
24
|
-
}
|
|
25
|
-
export interface PollResult {
|
|
26
|
-
events: FeishuEvent[];
|
|
27
|
-
error: string | null;
|
|
28
|
-
}
|
|
29
|
-
export declare function pollEvents(): Promise<PollResult>;
|
|
30
|
-
export declare function larkCliAvailable(): Promise<boolean>;
|
|
31
|
-
export declare function larkCliConfigured(): Promise<boolean>;
|
|
32
|
-
export declare function sendMessage(content: string, chatId: string, msgType?: "text" | "interactive"): Promise<boolean>;
|
|
33
|
-
export declare function downloadResource(messageId: string, fileKey: string, fileType: string, outputPath: string): Promise<boolean>;
|
package/dist/src/poller.js
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { promisify } from "node:util";
|
|
3
|
-
const execFileAsync = promisify(execFile);
|
|
4
|
-
export async function pollEvents() {
|
|
5
|
-
try {
|
|
6
|
-
const { stdout } = await execFileAsync("lark-cli", [
|
|
7
|
-
"im",
|
|
8
|
-
"+events-poll",
|
|
9
|
-
"--as",
|
|
10
|
-
"bot",
|
|
11
|
-
], { timeout: 30_000 });
|
|
12
|
-
const lines = stdout.trim().split("\n").filter(Boolean);
|
|
13
|
-
const events = [];
|
|
14
|
-
for (const line of lines) {
|
|
15
|
-
try {
|
|
16
|
-
const raw = JSON.parse(line);
|
|
17
|
-
events.push({
|
|
18
|
-
type: raw.type ?? "unknown",
|
|
19
|
-
event: raw.event,
|
|
20
|
-
raw,
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
catch {
|
|
24
|
-
// skip unparseable lines
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
return { events, error: null };
|
|
28
|
-
}
|
|
29
|
-
catch (err) {
|
|
30
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
31
|
-
return { events: [], error: message };
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
export async function larkCliAvailable() {
|
|
35
|
-
try {
|
|
36
|
-
await execFileAsync("lark-cli", ["--help"], { timeout: 5000 });
|
|
37
|
-
return true;
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
export async function larkCliConfigured() {
|
|
44
|
-
try {
|
|
45
|
-
const { stdout } = await execFileAsync("lark-cli", [
|
|
46
|
-
"config",
|
|
47
|
-
"show",
|
|
48
|
-
], { timeout: 5000 });
|
|
49
|
-
const config = JSON.parse(stdout);
|
|
50
|
-
return !!(config.appId && config.appSecret);
|
|
51
|
-
}
|
|
52
|
-
catch {
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
export async function sendMessage(content, chatId, msgType = "text") {
|
|
57
|
-
try {
|
|
58
|
-
const body = JSON.stringify({
|
|
59
|
-
receive_id: chatId,
|
|
60
|
-
msg_type: msgType,
|
|
61
|
-
content,
|
|
62
|
-
});
|
|
63
|
-
await execFileAsync("lark-cli", [
|
|
64
|
-
"im",
|
|
65
|
-
"messages",
|
|
66
|
-
"create",
|
|
67
|
-
"--data",
|
|
68
|
-
body,
|
|
69
|
-
"--as",
|
|
70
|
-
"bot",
|
|
71
|
-
], { timeout: 10_000 });
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return false;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
export async function downloadResource(messageId, fileKey, fileType, outputPath) {
|
|
79
|
-
try {
|
|
80
|
-
await execFileAsync("lark-cli", [
|
|
81
|
-
"im",
|
|
82
|
-
"+messages-resources-download",
|
|
83
|
-
"--message-id", messageId,
|
|
84
|
-
"--file-key", fileKey,
|
|
85
|
-
"--file-type", fileType,
|
|
86
|
-
"--output", outputPath,
|
|
87
|
-
"--as", "bot",
|
|
88
|
-
], { timeout: 30_000 });
|
|
89
|
-
return true;
|
|
90
|
-
}
|
|
91
|
-
catch {
|
|
92
|
-
return false;
|
|
93
|
-
}
|
|
94
|
-
}
|
package/dist/src/renderer.d.ts
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export declare const MESSAGE_MAX_LENGTH = 30000;
|
|
2
|
-
export interface FeishuTextMessage {
|
|
3
|
-
type: "text";
|
|
4
|
-
text: string;
|
|
5
|
-
}
|
|
6
|
-
export declare function renderText(text: string): FeishuTextMessage[];
|
|
7
|
-
export declare function renderCodeBlock(code: string, lang?: string): FeishuTextMessage[];
|
|
8
|
-
export declare function splitLongMessage(text: string): string[];
|
package/dist/src/renderer.js
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
export const MESSAGE_MAX_LENGTH = 30_000;
|
|
2
|
-
export function renderText(text) {
|
|
3
|
-
const parts = splitLongMessage(text);
|
|
4
|
-
return parts.map((part) => ({ type: "text", text: part }));
|
|
5
|
-
}
|
|
6
|
-
export function renderCodeBlock(code, lang) {
|
|
7
|
-
const header = lang ? `\`\`\`${lang}\n` : "```\n";
|
|
8
|
-
const text = header + code + "\n```";
|
|
9
|
-
return renderText(text);
|
|
10
|
-
}
|
|
11
|
-
export function splitLongMessage(text) {
|
|
12
|
-
if (text.length <= MESSAGE_MAX_LENGTH) {
|
|
13
|
-
return [text];
|
|
14
|
-
}
|
|
15
|
-
const parts = [];
|
|
16
|
-
let remaining = text;
|
|
17
|
-
while (remaining.length > 0) {
|
|
18
|
-
if (remaining.length <= MESSAGE_MAX_LENGTH) {
|
|
19
|
-
parts.push(remaining);
|
|
20
|
-
break;
|
|
21
|
-
}
|
|
22
|
-
let cutPoint = MESSAGE_MAX_LENGTH;
|
|
23
|
-
const newlineIdx = remaining.lastIndexOf("\n", MESSAGE_MAX_LENGTH);
|
|
24
|
-
if (newlineIdx > MESSAGE_MAX_LENGTH * 0.5) {
|
|
25
|
-
cutPoint = newlineIdx;
|
|
26
|
-
}
|
|
27
|
-
parts.push(remaining.slice(0, cutPoint));
|
|
28
|
-
remaining = remaining.slice(cutPoint);
|
|
29
|
-
}
|
|
30
|
-
return parts;
|
|
31
|
-
}
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
import type { ChatSessions, SessionInfo } from "./types.js";
|
|
2
|
-
export declare class SessionRegistry {
|
|
3
|
-
private registry;
|
|
4
|
-
private registryPath;
|
|
5
|
-
constructor(registryDir: string);
|
|
6
|
-
private load;
|
|
7
|
-
flush(): void;
|
|
8
|
-
private getOrCreateChat;
|
|
9
|
-
getChatSessions(chatId: string): ChatSessions | null;
|
|
10
|
-
getActiveSessionId(chatId: string): string | null;
|
|
11
|
-
ensureSession(chatId: string): SessionInfo;
|
|
12
|
-
createSession(chatId: string, name: string): SessionInfo;
|
|
13
|
-
switchSession(chatId: string, sessionId: string): boolean;
|
|
14
|
-
deleteSession(chatId: string, sessionId: string): boolean;
|
|
15
|
-
}
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
4
|
-
const REGISTRY_FILE = "registry.json";
|
|
5
|
-
export class SessionRegistry {
|
|
6
|
-
registry;
|
|
7
|
-
registryPath;
|
|
8
|
-
constructor(registryDir) {
|
|
9
|
-
this.registryPath = join(registryDir, REGISTRY_FILE);
|
|
10
|
-
this.registry = this.load();
|
|
11
|
-
}
|
|
12
|
-
load() {
|
|
13
|
-
if (!existsSync(this.registryPath))
|
|
14
|
-
return {};
|
|
15
|
-
try {
|
|
16
|
-
return JSON.parse(readFileSync(this.registryPath, "utf-8"));
|
|
17
|
-
}
|
|
18
|
-
catch {
|
|
19
|
-
return {};
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
flush() {
|
|
23
|
-
const dir = this.registryPath.replace(/\/[^/]+$/, "");
|
|
24
|
-
if (!existsSync(dir))
|
|
25
|
-
mkdirSync(dir, { recursive: true });
|
|
26
|
-
writeFileSync(this.registryPath, JSON.stringify(this.registry, null, 2));
|
|
27
|
-
}
|
|
28
|
-
getOrCreateChat(chatId) {
|
|
29
|
-
if (!this.registry[chatId]) {
|
|
30
|
-
this.registry[chatId] = { sessions: [], active: null };
|
|
31
|
-
}
|
|
32
|
-
return this.registry[chatId];
|
|
33
|
-
}
|
|
34
|
-
getChatSessions(chatId) {
|
|
35
|
-
return this.registry[chatId] ?? null;
|
|
36
|
-
}
|
|
37
|
-
getActiveSessionId(chatId) {
|
|
38
|
-
return this.registry[chatId]?.active ?? null;
|
|
39
|
-
}
|
|
40
|
-
ensureSession(chatId) {
|
|
41
|
-
const chat = this.getOrCreateChat(chatId);
|
|
42
|
-
if (chat.active && chat.sessions.find((s) => s.id === chat.active)) {
|
|
43
|
-
return chat.sessions.find((s) => s.id === chat.active);
|
|
44
|
-
}
|
|
45
|
-
return this.createSession(chatId, "默认会话");
|
|
46
|
-
}
|
|
47
|
-
createSession(chatId, name) {
|
|
48
|
-
const chat = this.getOrCreateChat(chatId);
|
|
49
|
-
const session = {
|
|
50
|
-
id: randomUUID(),
|
|
51
|
-
name,
|
|
52
|
-
createdAt: Date.now(),
|
|
53
|
-
};
|
|
54
|
-
chat.sessions.push(session);
|
|
55
|
-
chat.active = session.id;
|
|
56
|
-
this.flush();
|
|
57
|
-
return session;
|
|
58
|
-
}
|
|
59
|
-
switchSession(chatId, sessionId) {
|
|
60
|
-
const chat = this.registry[chatId];
|
|
61
|
-
if (!chat || !chat.sessions.find((s) => s.id === sessionId)) {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
chat.active = sessionId;
|
|
65
|
-
this.flush();
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
deleteSession(chatId, sessionId) {
|
|
69
|
-
const chat = this.registry[chatId];
|
|
70
|
-
if (!chat)
|
|
71
|
-
return false;
|
|
72
|
-
const idx = chat.sessions.findIndex((s) => s.id === sessionId);
|
|
73
|
-
if (idx === -1)
|
|
74
|
-
return false;
|
|
75
|
-
chat.sessions.splice(idx, 1);
|
|
76
|
-
if (chat.active === sessionId) {
|
|
77
|
-
chat.active = chat.sessions.length > 0 ? chat.sessions[0].id : null;
|
|
78
|
-
}
|
|
79
|
-
this.flush();
|
|
80
|
-
return true;
|
|
81
|
-
}
|
|
82
|
-
}
|
package/dist/src/types.d.ts
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
export interface FeishuImConfig {
|
|
2
|
-
strategy: "open" | "mention";
|
|
3
|
-
model?: string;
|
|
4
|
-
pollInterval: number;
|
|
5
|
-
autoStart?: boolean;
|
|
6
|
-
}
|
|
7
|
-
export interface SessionInfo {
|
|
8
|
-
id: string;
|
|
9
|
-
name: string;
|
|
10
|
-
createdAt: number;
|
|
11
|
-
}
|
|
12
|
-
export interface ChatSessions {
|
|
13
|
-
sessions: SessionInfo[];
|
|
14
|
-
active: string | null;
|
|
15
|
-
}
|
|
16
|
-
export interface Registry {
|
|
17
|
-
[chatId: string]: ChatSessions;
|
|
18
|
-
}
|
|
19
|
-
export interface DaemonStatus {
|
|
20
|
-
running: boolean;
|
|
21
|
-
pid: number | null;
|
|
22
|
-
uptime: number | null;
|
|
23
|
-
sessionCount: number;
|
|
24
|
-
chatCount: number;
|
|
25
|
-
}
|
package/dist/src/types.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/tests/bot.test.d.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/tests/bot.test.js
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach } from "vitest";
|
|
2
|
-
import { Bot } from "../src/bot.js";
|
|
3
|
-
import { SessionRegistry } from "../src/session-registry.js";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { existsSync, mkdirSync } from "node:fs";
|
|
7
|
-
function makeMsgEvent(chatId, text, mentions, threadId) {
|
|
8
|
-
return {
|
|
9
|
-
type: "im.message.receive_v1",
|
|
10
|
-
event: {
|
|
11
|
-
message: {
|
|
12
|
-
chat_id: chatId,
|
|
13
|
-
message_id: "om_" + Math.random().toString(36).slice(2),
|
|
14
|
-
parent_id: threadId,
|
|
15
|
-
message_type: "text",
|
|
16
|
-
content: JSON.stringify({ text }),
|
|
17
|
-
mentions,
|
|
18
|
-
},
|
|
19
|
-
sender: {
|
|
20
|
-
sender_id: { open_id: "ou_test" },
|
|
21
|
-
sender_type: "user",
|
|
22
|
-
},
|
|
23
|
-
},
|
|
24
|
-
raw: {},
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
describe("Bot routing", () => {
|
|
28
|
-
let tmpDir;
|
|
29
|
-
let registry;
|
|
30
|
-
let bot;
|
|
31
|
-
beforeEach(() => {
|
|
32
|
-
tmpDir = join(tmpdir(), "pi-feishu-cli-test-bot-" + Date.now());
|
|
33
|
-
if (!existsSync(tmpDir))
|
|
34
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
35
|
-
registry = new SessionRegistry(tmpDir);
|
|
36
|
-
bot = new Bot(registry, "mention");
|
|
37
|
-
});
|
|
38
|
-
it("detects /new command", () => {
|
|
39
|
-
const event = makeMsgEvent("oc_chat1", "/new 我的新会话");
|
|
40
|
-
const result = bot.route(event);
|
|
41
|
-
expect(result.type).toBe("command");
|
|
42
|
-
if (result.type === "command") {
|
|
43
|
-
expect(result.command).toBe("new");
|
|
44
|
-
}
|
|
45
|
-
});
|
|
46
|
-
it("detects /sessions command", () => {
|
|
47
|
-
const event = makeMsgEvent("oc_chat1", "/sessions");
|
|
48
|
-
const result = bot.route(event);
|
|
49
|
-
expect(result.type).toBe("command");
|
|
50
|
-
if (result.type === "command") {
|
|
51
|
-
expect(result.command).toBe("sessions");
|
|
52
|
-
}
|
|
53
|
-
});
|
|
54
|
-
it("detects /switch command", () => {
|
|
55
|
-
const event = makeMsgEvent("oc_chat1", "/switch sess_123");
|
|
56
|
-
const result = bot.route(event);
|
|
57
|
-
expect(result.type).toBe("command");
|
|
58
|
-
if (result.type === "command") {
|
|
59
|
-
expect(result.command).toBe("switch");
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
it("detects /rm command", () => {
|
|
63
|
-
const event = makeMsgEvent("oc_chat1", "/rm sess_123");
|
|
64
|
-
const result = bot.route(event);
|
|
65
|
-
expect(result.type).toBe("command");
|
|
66
|
-
if (result.type === "command") {
|
|
67
|
-
expect(result.command).toBe("rm");
|
|
68
|
-
}
|
|
69
|
-
});
|
|
70
|
-
it("detects /model command", () => {
|
|
71
|
-
const event = makeMsgEvent("oc_chat1", "/model");
|
|
72
|
-
const result = bot.route(event);
|
|
73
|
-
expect(result.type).toBe("command");
|
|
74
|
-
if (result.type === "command") {
|
|
75
|
-
expect(result.command).toBe("model");
|
|
76
|
-
}
|
|
77
|
-
});
|
|
78
|
-
it("routes regular text as message in mention mode", () => {
|
|
79
|
-
const event = makeMsgEvent("oc_chat1", "你好");
|
|
80
|
-
const result = bot.route(event);
|
|
81
|
-
expect(result.type).toBe("message");
|
|
82
|
-
});
|
|
83
|
-
it("routes all messages in open mode", () => {
|
|
84
|
-
const openBot = new Bot(registry, "open");
|
|
85
|
-
const event = makeMsgEvent("oc_chat1", "你好");
|
|
86
|
-
const result = openBot.route(event);
|
|
87
|
-
expect(result.type).toBe("message");
|
|
88
|
-
});
|
|
89
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/tests/cards.test.js
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { buildSessionListCard, buildModelSelectCard, } from "../src/cards.js";
|
|
3
|
-
describe("buildSessionListCard", () => {
|
|
4
|
-
const sessions = [
|
|
5
|
-
{ id: "abc", name: "修 bug", createdAt: 1700000000 },
|
|
6
|
-
{ id: "def", name: "新功能", createdAt: 1700000100 },
|
|
7
|
-
];
|
|
8
|
-
it("builds card with session entries", () => {
|
|
9
|
-
const card = buildSessionListCard("oc_chat1", sessions, "abc");
|
|
10
|
-
const json = JSON.parse(card);
|
|
11
|
-
expect(json.card.header).toBeDefined();
|
|
12
|
-
expect(json.card.elements).toBeDefined();
|
|
13
|
-
expect(JSON.stringify(json).length).toBeGreaterThan(100);
|
|
14
|
-
});
|
|
15
|
-
it("shows empty state when no sessions", () => {
|
|
16
|
-
const card = buildSessionListCard("oc_chat1", [], null);
|
|
17
|
-
const json = JSON.parse(card);
|
|
18
|
-
expect(JSON.stringify(json)).toContain("暂无会话");
|
|
19
|
-
});
|
|
20
|
-
it("marks active session", () => {
|
|
21
|
-
const card = buildSessionListCard("oc_chat1", sessions, "def");
|
|
22
|
-
const json = JSON.parse(card);
|
|
23
|
-
const str = JSON.stringify(json);
|
|
24
|
-
expect(str).toContain("def");
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
describe("buildModelSelectCard", () => {
|
|
28
|
-
it("builds card with model options", () => {
|
|
29
|
-
const models = [
|
|
30
|
-
{ id: "claude-sonnet", name: "Claude Sonnet" },
|
|
31
|
-
{ id: "gpt-4o", name: "GPT-4o" },
|
|
32
|
-
];
|
|
33
|
-
const card = buildModelSelectCard("oc_chat1", models, "claude-sonnet");
|
|
34
|
-
const json = JSON.parse(card);
|
|
35
|
-
expect(json.card.header).toBeDefined();
|
|
36
|
-
expect(JSON.stringify(json)).toContain("Claude Sonnet");
|
|
37
|
-
expect(JSON.stringify(json)).toContain("GPT-4o");
|
|
38
|
-
});
|
|
39
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { writeFileSync, unlinkSync, existsSync, mkdirSync, rmdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { loadConfig, DEFAULT_CONFIG } from "../src/config.js";
|
|
6
|
-
describe("loadConfig", () => {
|
|
7
|
-
const tmpDir = join(tmpdir(), "pi-feishu-cli-test-config");
|
|
8
|
-
const configPath = join(tmpDir, "config.json");
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
if (!existsSync(tmpDir))
|
|
11
|
-
mkdirSync(tmpDir, { recursive: true });
|
|
12
|
-
});
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
try {
|
|
15
|
-
unlinkSync(configPath);
|
|
16
|
-
}
|
|
17
|
-
catch { }
|
|
18
|
-
try {
|
|
19
|
-
rmdirSync(tmpDir);
|
|
20
|
-
}
|
|
21
|
-
catch { }
|
|
22
|
-
});
|
|
23
|
-
it("returns defaults when no config file exists", () => {
|
|
24
|
-
const config = loadConfig(tmpDir);
|
|
25
|
-
expect(config.strategy).toBe(DEFAULT_CONFIG.strategy);
|
|
26
|
-
expect(config.pollInterval).toBe(DEFAULT_CONFIG.pollInterval);
|
|
27
|
-
expect(config.model).toBeUndefined();
|
|
28
|
-
expect(config.autoStart).toBe(DEFAULT_CONFIG.autoStart);
|
|
29
|
-
});
|
|
30
|
-
it("loads and merges partial config", () => {
|
|
31
|
-
writeFileSync(configPath, JSON.stringify({ strategy: "open", pollInterval: 10 }));
|
|
32
|
-
const config = loadConfig(tmpDir);
|
|
33
|
-
expect(config.strategy).toBe("open");
|
|
34
|
-
expect(config.pollInterval).toBe(10);
|
|
35
|
-
});
|
|
36
|
-
it("loads full config", () => {
|
|
37
|
-
writeFileSync(configPath, JSON.stringify({
|
|
38
|
-
strategy: "mention",
|
|
39
|
-
model: "anthropic/claude-sonnet",
|
|
40
|
-
pollInterval: 3,
|
|
41
|
-
autoStart: true,
|
|
42
|
-
}));
|
|
43
|
-
const config = loadConfig(tmpDir);
|
|
44
|
-
expect(config.strategy).toBe("mention");
|
|
45
|
-
expect(config.model).toBe("anthropic/claude-sonnet");
|
|
46
|
-
expect(config.pollInterval).toBe(3);
|
|
47
|
-
expect(config.autoStart).toBe(true);
|
|
48
|
-
});
|
|
49
|
-
it("ignores extra unknown fields", () => {
|
|
50
|
-
writeFileSync(configPath, JSON.stringify({
|
|
51
|
-
strategy: "open",
|
|
52
|
-
pollInterval: 5,
|
|
53
|
-
unknownField: "should be ignored",
|
|
54
|
-
}));
|
|
55
|
-
const config = loadConfig(tmpDir);
|
|
56
|
-
expect(config.strategy).toBe("open");
|
|
57
|
-
expect(config.unknownField).toBeUndefined();
|
|
58
|
-
});
|
|
59
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { renderText, renderCodeBlock, splitLongMessage, MESSAGE_MAX_LENGTH, } from "../src/renderer.js";
|
|
3
|
-
describe("renderText", () => {
|
|
4
|
-
it("returns plain text unchanged", () => {
|
|
5
|
-
const result = renderText("hello world");
|
|
6
|
-
expect(result).toEqual([{ type: "text", text: "hello world" }]);
|
|
7
|
-
});
|
|
8
|
-
it("splits on large text", () => {
|
|
9
|
-
const long = "x".repeat(MESSAGE_MAX_LENGTH + 100);
|
|
10
|
-
const result = renderText(long);
|
|
11
|
-
expect(result.length).toBe(2);
|
|
12
|
-
expect(result[0].type).toBe("text");
|
|
13
|
-
expect(result[1].type).toBe("text");
|
|
14
|
-
});
|
|
15
|
-
it("handles empty text", () => {
|
|
16
|
-
const result = renderText("");
|
|
17
|
-
expect(result).toEqual([{ type: "text", text: "" }]);
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
describe("renderCodeBlock", () => {
|
|
21
|
-
it("wraps code in code block markers", () => {
|
|
22
|
-
const result = renderCodeBlock("console.log(1)", "javascript");
|
|
23
|
-
expect(result).toEqual([
|
|
24
|
-
{
|
|
25
|
-
type: "text",
|
|
26
|
-
text: "```javascript\nconsole.log(1)\n```",
|
|
27
|
-
},
|
|
28
|
-
]);
|
|
29
|
-
});
|
|
30
|
-
it("uses no language when lang not provided", () => {
|
|
31
|
-
const result = renderCodeBlock("print(1)");
|
|
32
|
-
expect(result).toEqual([
|
|
33
|
-
{
|
|
34
|
-
type: "text",
|
|
35
|
-
text: "```\nprint(1)\n```",
|
|
36
|
-
},
|
|
37
|
-
]);
|
|
38
|
-
});
|
|
39
|
-
});
|
|
40
|
-
describe("splitLongMessage", () => {
|
|
41
|
-
it("does not split short message", () => {
|
|
42
|
-
const result = splitLongMessage("short text");
|
|
43
|
-
expect(result).toEqual(["short text"]);
|
|
44
|
-
});
|
|
45
|
-
it("splits long message at newlines", () => {
|
|
46
|
-
const part1 = "a".repeat(Math.floor(MESSAGE_MAX_LENGTH * 0.6));
|
|
47
|
-
const part2 = "b".repeat(Math.floor(MESSAGE_MAX_LENGTH * 0.6));
|
|
48
|
-
const text = part1 + "\n" + part2;
|
|
49
|
-
const result = splitLongMessage(text);
|
|
50
|
-
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
51
|
-
const combined = result.join("");
|
|
52
|
-
expect(combined).toBe(text);
|
|
53
|
-
});
|
|
54
|
-
it("splits uniformly when no newlines", () => {
|
|
55
|
-
const long = "x".repeat(MESSAGE_MAX_LENGTH + 500);
|
|
56
|
-
const result = splitLongMessage(long);
|
|
57
|
-
expect(result.length).toBeGreaterThanOrEqual(2);
|
|
58
|
-
const combined = result.join("");
|
|
59
|
-
expect(combined).toBe(long);
|
|
60
|
-
});
|
|
61
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { existsSync, mkdirSync, unlinkSync, rmdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { tmpdir } from "node:os";
|
|
5
|
-
import { SessionRegistry } from "../src/session-registry.js";
|
|
6
|
-
describe("SessionRegistry", () => {
|
|
7
|
-
const tmpDir = join(tmpdir(), "pi-feishu-cli-test-registry");
|
|
8
|
-
const registryDir = join(tmpDir, "feishu-im");
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
if (!existsSync(registryDir))
|
|
11
|
-
mkdirSync(registryDir, { recursive: true });
|
|
12
|
-
});
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
try {
|
|
15
|
-
unlinkSync(join(registryDir, "registry.json"));
|
|
16
|
-
}
|
|
17
|
-
catch { }
|
|
18
|
-
try {
|
|
19
|
-
rmdirSync(registryDir);
|
|
20
|
-
}
|
|
21
|
-
catch { }
|
|
22
|
-
try {
|
|
23
|
-
rmdirSync(tmpDir);
|
|
24
|
-
}
|
|
25
|
-
catch { }
|
|
26
|
-
});
|
|
27
|
-
it("creates a new session for a new chat", () => {
|
|
28
|
-
const reg = new SessionRegistry(registryDir);
|
|
29
|
-
const session = reg.ensureSession("oc_chat1");
|
|
30
|
-
expect(session.name).toBe("默认会话");
|
|
31
|
-
expect(session.id).toBeDefined();
|
|
32
|
-
const data = reg.getChatSessions("oc_chat1");
|
|
33
|
-
expect(data.sessions).toHaveLength(1);
|
|
34
|
-
expect(data.active).toBe(session.id);
|
|
35
|
-
});
|
|
36
|
-
it("reuses active session on subsequent calls", () => {
|
|
37
|
-
const reg = new SessionRegistry(registryDir);
|
|
38
|
-
const s1 = reg.ensureSession("oc_chat1");
|
|
39
|
-
const s2 = reg.ensureSession("oc_chat1");
|
|
40
|
-
expect(s2.id).toBe(s1.id);
|
|
41
|
-
const data = reg.getChatSessions("oc_chat1");
|
|
42
|
-
expect(data.sessions).toHaveLength(1);
|
|
43
|
-
});
|
|
44
|
-
it("creates a new session via command", () => {
|
|
45
|
-
const reg = new SessionRegistry(registryDir);
|
|
46
|
-
reg.ensureSession("oc_chat1");
|
|
47
|
-
const s2 = reg.createSession("oc_chat1", "新功能开发");
|
|
48
|
-
expect(s2.name).toBe("新功能开发");
|
|
49
|
-
const data = reg.getChatSessions("oc_chat1");
|
|
50
|
-
expect(data.sessions).toHaveLength(2);
|
|
51
|
-
expect(data.active).toBe(s2.id);
|
|
52
|
-
});
|
|
53
|
-
it("switches active session", () => {
|
|
54
|
-
const reg = new SessionRegistry(registryDir);
|
|
55
|
-
const s1 = reg.ensureSession("oc_chat1");
|
|
56
|
-
const s2 = reg.createSession("oc_chat1", "test2");
|
|
57
|
-
expect(reg.getActiveSessionId("oc_chat1")).toBe(s2.id);
|
|
58
|
-
reg.switchSession("oc_chat1", s1.id);
|
|
59
|
-
expect(reg.getActiveSessionId("oc_chat1")).toBe(s1.id);
|
|
60
|
-
});
|
|
61
|
-
it("deletes a session", () => {
|
|
62
|
-
const reg = new SessionRegistry(registryDir);
|
|
63
|
-
const s1 = reg.ensureSession("oc_chat1");
|
|
64
|
-
const s2 = reg.createSession("oc_chat1", "to-delete");
|
|
65
|
-
reg.deleteSession("oc_chat1", s2.id);
|
|
66
|
-
const data = reg.getChatSessions("oc_chat1");
|
|
67
|
-
expect(data.sessions).toHaveLength(1);
|
|
68
|
-
expect(data.sessions[0].id).toBe(s1.id);
|
|
69
|
-
});
|
|
70
|
-
it("deleting active session switches to another", () => {
|
|
71
|
-
const reg = new SessionRegistry(registryDir);
|
|
72
|
-
const s1 = reg.ensureSession("oc_chat1");
|
|
73
|
-
reg.createSession("oc_chat1", "test2");
|
|
74
|
-
reg.deleteSession("oc_chat1", reg.getActiveSessionId("oc_chat1"));
|
|
75
|
-
expect(reg.getActiveSessionId("oc_chat1")).toBe(s1.id);
|
|
76
|
-
});
|
|
77
|
-
it("persists and loads registry", () => {
|
|
78
|
-
const reg1 = new SessionRegistry(registryDir);
|
|
79
|
-
const s1 = reg1.ensureSession("oc_chat1");
|
|
80
|
-
reg1.createSession("oc_chat1", "second");
|
|
81
|
-
reg1.flush();
|
|
82
|
-
const reg2 = new SessionRegistry(registryDir);
|
|
83
|
-
const data = reg2.getChatSessions("oc_chat1");
|
|
84
|
-
expect(data.sessions).toHaveLength(2);
|
|
85
|
-
expect(data.active).toBeDefined();
|
|
86
|
-
});
|
|
87
|
-
it("returns null for unknown chat", () => {
|
|
88
|
-
const reg = new SessionRegistry(registryDir);
|
|
89
|
-
expect(reg.getChatSessions("nonexistent")).toBeNull();
|
|
90
|
-
expect(reg.getActiveSessionId("nonexistent")).toBeNull();
|
|
91
|
-
});
|
|
92
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
package/dist/tests/types.test.js
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
describe("type definitions", () => {
|
|
3
|
-
it("Registry shape", () => {
|
|
4
|
-
const registry = {
|
|
5
|
-
"oc_xxx": {
|
|
6
|
-
sessions: [
|
|
7
|
-
{ id: "sess_1", name: "修 bug", createdAt: 1700000000 },
|
|
8
|
-
],
|
|
9
|
-
active: "sess_1",
|
|
10
|
-
},
|
|
11
|
-
};
|
|
12
|
-
expect(registry["oc_xxx"].sessions).toHaveLength(1);
|
|
13
|
-
expect(registry["oc_xxx"].active).toBe("sess_1");
|
|
14
|
-
});
|
|
15
|
-
it("FeishuImConfig defaults", () => {
|
|
16
|
-
const config = { strategy: "mention", pollInterval: 5 };
|
|
17
|
-
expect(config.strategy).toBe("mention");
|
|
18
|
-
expect(config.autoStart).toBeUndefined();
|
|
19
|
-
});
|
|
20
|
-
it("SessionInfo fields", () => {
|
|
21
|
-
const info = { id: "abc", name: "test", createdAt: 123 };
|
|
22
|
-
expect(info.id).toBe("abc");
|
|
23
|
-
expect(info.name).toBe("test");
|
|
24
|
-
expect(info.createdAt).toBe(123);
|
|
25
|
-
});
|
|
26
|
-
it("ChatSessions active can be null", () => {
|
|
27
|
-
const cs = { sessions: [], active: null };
|
|
28
|
-
expect(cs.active).toBeNull();
|
|
29
|
-
});
|
|
30
|
-
});
|