pi-telegram-plus 0.0.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/README.md +318 -0
- package/index.ts +327 -0
- package/lib/attachments.ts +154 -0
- package/lib/callback-protocol.ts +9 -0
- package/lib/command-parser.ts +15 -0
- package/lib/commands/auth.ts +365 -0
- package/lib/commands/info.ts +158 -0
- package/lib/commands/lifecycle.ts +65 -0
- package/lib/commands/model.ts +188 -0
- package/lib/commands/register.ts +40 -0
- package/lib/commands/session.ts +281 -0
- package/lib/commands/settings.ts +129 -0
- package/lib/commands/telegram-commands.ts +162 -0
- package/lib/commands/tg-config.ts +128 -0
- package/lib/config.ts +171 -0
- package/lib/controller.ts +406 -0
- package/lib/heartbeat.ts +95 -0
- package/lib/html.ts +6 -0
- package/lib/markdown.ts +132 -0
- package/lib/menu-commands.ts +72 -0
- package/lib/polling.ts +255 -0
- package/lib/renderer.ts +284 -0
- package/lib/session-capture.ts +95 -0
- package/lib/status.ts +46 -0
- package/lib/telegram-api.ts +327 -0
- package/lib/telegram-ui.ts +208 -0
- package/lib/text-split.ts +59 -0
- package/lib/types.ts +123 -0
- package/package.json +53 -0
- package/pi-host.d.ts +8 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import type { CommandRegistry, TgConfigDeps } from "./register.ts";
|
|
2
|
+
import type { TelegramConfig, TelegramMessageMode, TelegramRenderLevel } from "../types.ts";
|
|
3
|
+
import { RENDER_LEVELS, MODE_VALUES } from "../types.ts";
|
|
4
|
+
|
|
5
|
+
const KEY_LABELS: Record<string, string> = {
|
|
6
|
+
tool: "🔧 Tool rendering",
|
|
7
|
+
thinking: "💭 Thinking rendering",
|
|
8
|
+
mode: "📨 Message mode",
|
|
9
|
+
retry: "🔄 Retry count",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export function registerTgConfigCommands(
|
|
13
|
+
registry: CommandRegistry,
|
|
14
|
+
deps: TgConfigDeps,
|
|
15
|
+
): void {
|
|
16
|
+
registry.registerCommand("tg-config", {
|
|
17
|
+
description: "Configure Telegram message rendering and mode",
|
|
18
|
+
handler: async (args, ctx) => {
|
|
19
|
+
const ui = ctx.ui;
|
|
20
|
+
const parts = args.trim().split(/\s+/);
|
|
21
|
+
|
|
22
|
+
// Direct-set mode: /tg-config <key> <value>
|
|
23
|
+
if (parts.length >= 2 && parts[0]) {
|
|
24
|
+
const key = parts[0];
|
|
25
|
+
const value = parts[1];
|
|
26
|
+
const config = deps.getConfig();
|
|
27
|
+
|
|
28
|
+
if (key === "tool" || key === "thinking") {
|
|
29
|
+
if (!(RENDER_LEVELS as readonly string[]).includes(value)) {
|
|
30
|
+
ui.notify("Invalid. Use: /tg-config <tool|thinking> <hidden|brief|full>", "error");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const next = key === "tool"
|
|
34
|
+
? { ...config, tool: value as TelegramRenderLevel }
|
|
35
|
+
: { ...config, thinking: value as TelegramRenderLevel };
|
|
36
|
+
deps.setConfig(next);
|
|
37
|
+
await deps.persistConfig(next);
|
|
38
|
+
ui.notify(`${key} set to ${value}`, "info");
|
|
39
|
+
return;
|
|
40
|
+
} else if (key === "mode") {
|
|
41
|
+
if (!(MODE_VALUES as readonly string[]).includes(value)) {
|
|
42
|
+
ui.notify("Invalid. Use: /tg-config mode <queue|steer>", "error");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const next = { ...config, messageMode: value as TelegramMessageMode };
|
|
46
|
+
deps.setConfig(next);
|
|
47
|
+
await deps.persistConfig(next);
|
|
48
|
+
ui.notify(`mode set to ${value}`, "info");
|
|
49
|
+
return;
|
|
50
|
+
} else if (key === "retry") {
|
|
51
|
+
const n = parseInt(value, 10);
|
|
52
|
+
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
|
53
|
+
ui.notify("Invalid. Use: /tg-config retry <0-10>", "error");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const next = { ...config, retryCount: n };
|
|
57
|
+
deps.setConfig(next);
|
|
58
|
+
await deps.persistConfig(next);
|
|
59
|
+
ui.notify(`retryCount set to ${n}`, "info");
|
|
60
|
+
return;
|
|
61
|
+
} else {
|
|
62
|
+
ui.notify("Invalid key. Use: tool, thinking, mode, or retry", "error");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Interactive mode
|
|
68
|
+
const config = deps.getConfig();
|
|
69
|
+
const currentTool = config.tool ?? "brief";
|
|
70
|
+
const currentThinking = config.thinking ?? "brief";
|
|
71
|
+
const currentMode = config.messageMode ?? "steer";
|
|
72
|
+
const currentRetry = config.retryCount ?? 3;
|
|
73
|
+
|
|
74
|
+
const choice = await ui.select("⚙️ Telegram Config", [
|
|
75
|
+
`${KEY_LABELS.tool}: ${currentTool}`,
|
|
76
|
+
`${KEY_LABELS.thinking}: ${currentThinking}`,
|
|
77
|
+
`${KEY_LABELS.mode}: ${currentMode}`,
|
|
78
|
+
`${KEY_LABELS.retry}: ${currentRetry}`,
|
|
79
|
+
]);
|
|
80
|
+
if (!choice) return;
|
|
81
|
+
|
|
82
|
+
let selectedKey: string;
|
|
83
|
+
let current: string;
|
|
84
|
+
|
|
85
|
+
if (choice.startsWith(KEY_LABELS.tool)) {
|
|
86
|
+
selectedKey = "tool";
|
|
87
|
+
current = currentTool;
|
|
88
|
+
} else if (choice.startsWith(KEY_LABELS.thinking)) {
|
|
89
|
+
selectedKey = "thinking";
|
|
90
|
+
current = currentThinking;
|
|
91
|
+
} else if (choice.startsWith(KEY_LABELS.mode)) {
|
|
92
|
+
selectedKey = "mode";
|
|
93
|
+
current = currentMode;
|
|
94
|
+
} else if (choice.startsWith(KEY_LABELS.retry)) {
|
|
95
|
+
// Retry count is a number, not a select from list
|
|
96
|
+
const input = await ui.input("Retry count (0-10)", `Current: ${currentRetry}`);
|
|
97
|
+
if (!input) return;
|
|
98
|
+
const n = parseInt(input, 10);
|
|
99
|
+
if (!Number.isInteger(n) || n < 0 || n > 10) {
|
|
100
|
+
ui.notify("Must be a number 0-10", "error");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const next = { ...config, retryCount: n };
|
|
104
|
+
deps.setConfig(next);
|
|
105
|
+
await deps.persistConfig(next);
|
|
106
|
+
ui.notify(`${KEY_LABELS.retry} set to ${n}`, "info");
|
|
107
|
+
return;
|
|
108
|
+
} else {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const values = selectedKey === "mode" ? [...MODE_VALUES] : [...RENDER_LEVELS];
|
|
113
|
+
const labels = values.map((v) => (v === current ? `● ${v}` : ` ${v}`));
|
|
114
|
+
|
|
115
|
+
const valueChoice = await ui.select(KEY_LABELS[selectedKey], labels);
|
|
116
|
+
if (!valueChoice) return;
|
|
117
|
+
|
|
118
|
+
const idx = labels.indexOf(valueChoice);
|
|
119
|
+
if (idx < 0 || idx >= values.length) return;
|
|
120
|
+
const selectedValue = values[idx];
|
|
121
|
+
|
|
122
|
+
const next = { ...config, [selectedKey === "mode" ? "messageMode" : selectedKey]: selectedValue };
|
|
123
|
+
deps.setConfig(next);
|
|
124
|
+
await deps.persistConfig(next);
|
|
125
|
+
ui.notify(`${KEY_LABELS[selectedKey]} set to ${selectedValue}`, "info");
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
}
|
package/lib/config.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { chmod, mkdir, readFile, rm, rmdir, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, relative, resolve } from "node:path";
|
|
5
|
+
import type { ResolvedTelegramConfig, TelegramConfig, TelegramConfigStore, TelegramWorkspaceConfig } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export function getAgentDir(): string {
|
|
8
|
+
return process.env.PI_CODING_AGENT_DIR
|
|
9
|
+
? resolve(process.env.PI_CODING_AGENT_DIR)
|
|
10
|
+
: join(homedir(), ".pi", "agent");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getTelegramConfigPath(): string {
|
|
14
|
+
return join(getAgentDir(), "tg.json");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function emptyStore(): TelegramConfigStore {
|
|
18
|
+
return { version: 2, global: {}, workspaces: [] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
22
|
+
|
|
23
|
+
async function withTelegramConfigLock<T>(run: () => Promise<T>): Promise<T> {
|
|
24
|
+
await mkdir(getAgentDir(), { recursive: true });
|
|
25
|
+
const lockPath = join(getAgentDir(), "tg.json.lock");
|
|
26
|
+
const started = Date.now();
|
|
27
|
+
while (true) {
|
|
28
|
+
try {
|
|
29
|
+
await mkdir(lockPath, { mode: 0o700 });
|
|
30
|
+
break;
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
33
|
+
if (code !== "EEXIST") throw error;
|
|
34
|
+
const age = Date.now() - (await stat(lockPath).then((s) => s.mtimeMs).catch(() => Date.now()));
|
|
35
|
+
if (age > 30_000 || Date.now() - started > 10_000) {
|
|
36
|
+
await rm(lockPath, { recursive: true, force: true }).catch(() => undefined);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
await sleep(50);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
return await run();
|
|
44
|
+
} finally {
|
|
45
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function assertV2Store(value: unknown): TelegramConfigStore {
|
|
50
|
+
if (!value || typeof value !== "object" || (value as { version?: unknown }).version !== 2) {
|
|
51
|
+
throw new Error("Unsupported Telegram config format. Please recreate ~/.pi/agent/tg.json as version 2 or run /tg-setup.");
|
|
52
|
+
}
|
|
53
|
+
const store = value as TelegramConfigStore;
|
|
54
|
+
return {
|
|
55
|
+
version: 2,
|
|
56
|
+
global: store.global ?? {},
|
|
57
|
+
workspaces: Array.isArray(store.workspaces) ? store.workspaces : [],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function readTelegramConfigStore(): Promise<TelegramConfigStore> {
|
|
62
|
+
const path = getTelegramConfigPath();
|
|
63
|
+
if (!existsSync(path)) return emptyStore();
|
|
64
|
+
return assertV2Store(JSON.parse(await readFile(path, "utf8")));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function writeTelegramConfigStore(store: TelegramConfigStore): Promise<void> {
|
|
68
|
+
await mkdir(getAgentDir(), { recursive: true });
|
|
69
|
+
const path = getTelegramConfigPath();
|
|
70
|
+
const normalized: TelegramConfigStore = {
|
|
71
|
+
version: 2,
|
|
72
|
+
global: store.global ?? {},
|
|
73
|
+
workspaces: store.workspaces ?? [],
|
|
74
|
+
};
|
|
75
|
+
await writeFile(path, JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
|
|
76
|
+
await chmod(path, 0o600).catch(() => undefined);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function normalizePath(path: string): string {
|
|
80
|
+
return resolve(path);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isPathInsideOrEqual(child: string, parent: string): boolean {
|
|
84
|
+
const rel = relative(parent, child);
|
|
85
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !rel.startsWith("/"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function resolveTelegramConfigStore(store: TelegramConfigStore, cwd: string): ResolvedTelegramConfig {
|
|
89
|
+
const normalizedCwd = normalizePath(cwd);
|
|
90
|
+
const workspaces = store.workspaces ?? [];
|
|
91
|
+
const match = workspaces
|
|
92
|
+
.map((workspace) => ({ ...workspace, path: normalizePath(workspace.path) }))
|
|
93
|
+
.filter((workspace) => isPathInsideOrEqual(normalizedCwd, workspace.path))
|
|
94
|
+
.sort((a, b) => b.path.length - a.path.length)[0];
|
|
95
|
+
|
|
96
|
+
if (match) {
|
|
97
|
+
return { store, scope: "workspace", workspacePath: match.path, config: match.config ?? {} };
|
|
98
|
+
}
|
|
99
|
+
return { store, scope: "global", config: store.global ?? {} };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readResolvedTelegramConfig(cwd: string): Promise<ResolvedTelegramConfig> {
|
|
103
|
+
return resolveTelegramConfigStore(await readTelegramConfigStore(), cwd);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function mergeTelegramConfigForWrite(existing: TelegramConfig | undefined, incoming: TelegramConfig): TelegramConfig {
|
|
107
|
+
const next = { ...(incoming ?? {}) };
|
|
108
|
+
const existingOffset = existing?.lastUpdateId;
|
|
109
|
+
const incomingOffset = incoming.lastUpdateId;
|
|
110
|
+
if (typeof existingOffset === "number" || typeof incomingOffset === "number") {
|
|
111
|
+
next.lastUpdateId = Math.max(
|
|
112
|
+
typeof existingOffset === "number" ? existingOffset : -1,
|
|
113
|
+
typeof incomingOffset === "number" ? incomingOffset : -1,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function writeResolvedTelegramConfig(resolved: ResolvedTelegramConfig, config: TelegramConfig): Promise<ResolvedTelegramConfig> {
|
|
120
|
+
return await withTelegramConfigLock(async () => {
|
|
121
|
+
// Re-read the store while holding the lock. Multiple pi instances / workspace
|
|
122
|
+
// bots can persist polling offsets and active chats concurrently; writing the
|
|
123
|
+
// stale session_start snapshot would overwrite other bots' newer workspace config.
|
|
124
|
+
// Preserve lastUpdateId monotonically so stale async handlers cannot regress
|
|
125
|
+
// the durable Telegram offset after polling has advanced it.
|
|
126
|
+
const store = await readTelegramConfigStore();
|
|
127
|
+
if (resolved.scope === "workspace" && resolved.workspacePath) {
|
|
128
|
+
const workspacePath = normalizePath(resolved.workspacePath);
|
|
129
|
+
const workspaces = store.workspaces ?? [];
|
|
130
|
+
const index = workspaces.findIndex((workspace) => normalizePath(workspace.path) === workspacePath);
|
|
131
|
+
const existing = index >= 0 ? workspaces[index].config : undefined;
|
|
132
|
+
const nextConfig = mergeTelegramConfigForWrite(existing, config);
|
|
133
|
+
if (index >= 0) workspaces[index] = { path: workspacePath, config: nextConfig };
|
|
134
|
+
else workspaces.push({ path: workspacePath, config: nextConfig });
|
|
135
|
+
store.workspaces = workspaces;
|
|
136
|
+
} else {
|
|
137
|
+
store.global = mergeTelegramConfigForWrite(store.global, config);
|
|
138
|
+
}
|
|
139
|
+
await writeTelegramConfigStore(store);
|
|
140
|
+
return resolveTelegramConfigStore(store, resolved.workspacePath ?? process.cwd());
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function bindWorkspaceTelegramConfig(cwd: string, config: TelegramConfig): Promise<ResolvedTelegramConfig> {
|
|
145
|
+
return await withTelegramConfigLock(async () => {
|
|
146
|
+
const store = await readTelegramConfigStore();
|
|
147
|
+
const workspacePath = normalizePath(cwd);
|
|
148
|
+
const workspaces = store.workspaces ?? [];
|
|
149
|
+
const index = workspaces.findIndex((workspace) => normalizePath(workspace.path) === workspacePath);
|
|
150
|
+
const entry: TelegramWorkspaceConfig = { path: workspacePath, config };
|
|
151
|
+
if (index >= 0) workspaces[index] = entry;
|
|
152
|
+
else workspaces.push(entry);
|
|
153
|
+
workspaces.sort((a, b) => normalizePath(a.path).localeCompare(normalizePath(b.path)));
|
|
154
|
+
store.workspaces = workspaces;
|
|
155
|
+
await writeTelegramConfigStore(store);
|
|
156
|
+
return resolveTelegramConfigStore(store, workspacePath);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function unbindWorkspaceTelegramConfig(cwd: string): Promise<ResolvedTelegramConfig> {
|
|
161
|
+
return await withTelegramConfigLock(async () => {
|
|
162
|
+
const store = await readTelegramConfigStore();
|
|
163
|
+
const current = resolveTelegramConfigStore(store, cwd);
|
|
164
|
+
if (current.scope === "workspace" && current.workspacePath) {
|
|
165
|
+
const workspacePath = normalizePath(current.workspacePath);
|
|
166
|
+
store.workspaces = (store.workspaces ?? []).filter((workspace) => normalizePath(workspace.path) !== workspacePath);
|
|
167
|
+
await writeTelegramConfigStore(store);
|
|
168
|
+
}
|
|
169
|
+
return resolveTelegramConfigStore(store, cwd);
|
|
170
|
+
});
|
|
171
|
+
}
|