talkiebot 0.1.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/README.md +263 -0
- package/bin/talkie-server.js +336 -0
- package/bin/talkie.js +43 -0
- package/dist/Talkie_logo.png +0 -0
- package/dist/assets/index-C-Y2BbXt.css +1 -0
- package/dist/assets/index-JS88FEbt.js +81 -0
- package/dist/index.html +14 -0
- package/mcp-server/index.js +694 -0
- package/package.json +70 -0
- package/server/api.js +614 -0
- package/server/db/index.js +57 -0
- package/server/db/repositories/activities.js +85 -0
- package/server/db/repositories/conversations.js +93 -0
- package/server/db/repositories/jobs.js +128 -0
- package/server/db/repositories/messages.js +98 -0
- package/server/db/repositories/plans.js +57 -0
- package/server/db/repositories/search.js +34 -0
- package/server/db/repositories/telegram.js +30 -0
- package/server/db/schema.js +165 -0
- package/server/index.js +137 -0
- package/server/jobs/api.js +108 -0
- package/server/jobs/manager.js +231 -0
- package/server/jobs/runner.js +246 -0
- package/server/notifications/dispatcher.js +40 -0
- package/server/notifications/macos.js +24 -0
- package/server/notifications/types.js +0 -0
- package/server/ssl.js +58 -0
- package/server/state.js +30 -0
- package/server/telegram/commands.js +160 -0
- package/server/telegram/handlers.js +299 -0
- package/server/telegram/index.js +46 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { writeFileSync, mkdirSync, unlinkSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
function detectPlanFromTool(toolName, input) {
|
|
6
|
+
if (toolName !== "Write" && toolName !== "Edit") return null;
|
|
7
|
+
const filePath = input.file_path || "";
|
|
8
|
+
const content = input.content || input.new_string || "";
|
|
9
|
+
if (!content || content.length < 100) return null;
|
|
10
|
+
const isPlanFile = /plan/i.test(filePath);
|
|
11
|
+
const headingCount = (content.match(/^#{1,3}\s+.+/gm) || []).length;
|
|
12
|
+
const listItemCount = (content.match(/^(?:\d+\.|[-*])\s+/gm) || []).length;
|
|
13
|
+
const hasPlanHeading = /^#{1,3}\s+.*(?:plan|implementation|approach|strategy|roadmap|phases?|proposal)/im.test(content);
|
|
14
|
+
const hasStructure = headingCount >= 2 && listItemCount >= 4;
|
|
15
|
+
if (!isPlanFile && !hasPlanHeading && !hasStructure) return null;
|
|
16
|
+
let title = "Untitled Plan";
|
|
17
|
+
const titleMatch = content.match(/^#{1,3}\s+(.*(?:plan|implementation|approach|strategy|roadmap|phases?|proposal).*)/im);
|
|
18
|
+
if (titleMatch) {
|
|
19
|
+
title = titleMatch[1].replace(/\*\*/g, "").replace(/`/g, "").trim();
|
|
20
|
+
} else {
|
|
21
|
+
const firstHeading = content.match(/^#{1,3}\s+(.+)/m);
|
|
22
|
+
if (firstHeading) {
|
|
23
|
+
title = firstHeading[1].replace(/\*\*/g, "").replace(/`/g, "").trim();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (title.length > 100) title = title.slice(0, 97) + "...";
|
|
27
|
+
return { title, content };
|
|
28
|
+
}
|
|
29
|
+
function spawnClaude(options) {
|
|
30
|
+
const { prompt, history, images, rawMode, callbacks } = options;
|
|
31
|
+
const tempImagePaths = [];
|
|
32
|
+
if (images && images.length > 0) {
|
|
33
|
+
const tempDir = join(tmpdir(), "talkie-images");
|
|
34
|
+
mkdirSync(tempDir, { recursive: true });
|
|
35
|
+
for (const img of images) {
|
|
36
|
+
const base64Data = img.dataUrl.split(",")[1];
|
|
37
|
+
if (!base64Data) continue;
|
|
38
|
+
const ext = img.fileName.split(".").pop() || "png";
|
|
39
|
+
const tempPath = join(tempDir, `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`);
|
|
40
|
+
writeFileSync(tempPath, Buffer.from(base64Data, "base64"));
|
|
41
|
+
tempImagePaths.push(tempPath);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
let fullPrompt;
|
|
45
|
+
if (rawMode) {
|
|
46
|
+
let imageBlock = "";
|
|
47
|
+
if (tempImagePaths.length > 0) {
|
|
48
|
+
imageBlock = "Read these image files and then follow the instructions below:\n" + tempImagePaths.map((p) => p).join("\n") + "\n\n";
|
|
49
|
+
}
|
|
50
|
+
fullPrompt = `${imageBlock}${prompt}`;
|
|
51
|
+
} else {
|
|
52
|
+
const recentMessages = (history || []).slice(-10);
|
|
53
|
+
let contextBlock = "";
|
|
54
|
+
if (recentMessages.length > 0) {
|
|
55
|
+
contextBlock = "[Recent conversation]\n" + recentMessages.map((m) => `${m.role === "user" ? "User" : "Assistant"}: ${m.content}`).join("\n") + "\n[/Recent conversation]\n\n";
|
|
56
|
+
}
|
|
57
|
+
let imageBlock = "";
|
|
58
|
+
if (tempImagePaths.length > 0) {
|
|
59
|
+
imageBlock = "[Attached Images - Use the Read tool to view these image files]\n" + tempImagePaths.map((p) => p).join("\n") + "\n[/Attached Images]\n\n";
|
|
60
|
+
}
|
|
61
|
+
const isPlanRequest = /\b(?:plan|design|architect|propose|strategy|roadmap|outline)\b/i.test(prompt);
|
|
62
|
+
const planInstruction = isPlanRequest ? "\n[PLAN MODE - The user is asking you to make a plan. Write the full detailed plan (with markdown headings, numbered steps, etc.) to a file using the Write tool at /tmp/talkie-plan.md. Then give a brief voice summary of what you planned.]" : "";
|
|
63
|
+
fullPrompt = `${contextBlock}${imageBlock}[VOICE MODE - Keep responses to 1-2 sentences, no markdown, speak naturally]${planInstruction}
|
|
64
|
+
|
|
65
|
+
User: ${prompt}`;
|
|
66
|
+
}
|
|
67
|
+
const args = [
|
|
68
|
+
"-p",
|
|
69
|
+
fullPrompt,
|
|
70
|
+
"--output-format",
|
|
71
|
+
"stream-json",
|
|
72
|
+
"--verbose",
|
|
73
|
+
"--permission-mode",
|
|
74
|
+
"bypassPermissions",
|
|
75
|
+
"--no-session-persistence"
|
|
76
|
+
];
|
|
77
|
+
const claudePath = process.env.CLAUDE_PATH || "claude";
|
|
78
|
+
console.log("Spawning claude:", claudePath, "prompt length:", fullPrompt.length, rawMode ? "(raw mode)" : "(voice mode)");
|
|
79
|
+
const env = { ...process.env, FORCE_COLOR: "0" };
|
|
80
|
+
delete env.CLAUDECODE;
|
|
81
|
+
const claude = spawn(claudePath, args, {
|
|
82
|
+
cwd: process.cwd(),
|
|
83
|
+
env,
|
|
84
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
85
|
+
detached: false
|
|
86
|
+
});
|
|
87
|
+
let buffer = "";
|
|
88
|
+
const toolInputs = {};
|
|
89
|
+
const toolNames = {};
|
|
90
|
+
let currentToolId = null;
|
|
91
|
+
claude.stdout.on("data", (data) => {
|
|
92
|
+
buffer += data.toString();
|
|
93
|
+
const lines = buffer.split("\n");
|
|
94
|
+
buffer = lines.pop() || "";
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
if (!line.trim()) continue;
|
|
97
|
+
try {
|
|
98
|
+
const event = JSON.parse(line);
|
|
99
|
+
if (event.type === "assistant") {
|
|
100
|
+
const textContent = event.message?.content?.find((c) => c.type === "text");
|
|
101
|
+
if (textContent?.text) {
|
|
102
|
+
let text = textContent.text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, "");
|
|
103
|
+
if (text.trim()) {
|
|
104
|
+
callbacks.onText(text);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const toolUseBlocks = event.message?.content?.filter((c) => c.type === "tool_use") || [];
|
|
108
|
+
for (const toolBlock of toolUseBlocks) {
|
|
109
|
+
if (toolBlock.id && toolBlock.input) {
|
|
110
|
+
toolInputs[toolBlock.id] = JSON.stringify(toolBlock.input);
|
|
111
|
+
}
|
|
112
|
+
let inputDetail = "";
|
|
113
|
+
if (toolBlock.input) {
|
|
114
|
+
if (toolBlock.input.file_path) inputDetail = toolBlock.input.file_path;
|
|
115
|
+
else if (toolBlock.input.command) inputDetail = toolBlock.input.command;
|
|
116
|
+
else if (toolBlock.input.pattern) inputDetail = toolBlock.input.pattern;
|
|
117
|
+
}
|
|
118
|
+
callbacks.onActivity({
|
|
119
|
+
type: "tool_start",
|
|
120
|
+
tool: toolBlock.name,
|
|
121
|
+
id: toolBlock.id,
|
|
122
|
+
input: inputDetail
|
|
123
|
+
});
|
|
124
|
+
if (callbacks.onPlan && toolBlock.input) {
|
|
125
|
+
const plan = detectPlanFromTool(toolBlock.name, toolBlock.input);
|
|
126
|
+
if (plan) callbacks.onPlan(plan);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
} else if (event.type === "content_block_start") {
|
|
130
|
+
if (event.content_block?.type === "tool_use") {
|
|
131
|
+
currentToolId = event.content_block.id;
|
|
132
|
+
toolInputs[currentToolId] = "";
|
|
133
|
+
toolNames[currentToolId] = event.content_block.name;
|
|
134
|
+
callbacks.onActivity({
|
|
135
|
+
type: "tool_start",
|
|
136
|
+
tool: event.content_block.name,
|
|
137
|
+
id: event.content_block.id
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
} else if (event.type === "content_block_delta") {
|
|
141
|
+
if (event.delta?.type === "text_delta" && event.delta?.text) {
|
|
142
|
+
let text = event.delta.text.replace(/<thinking>[\s\S]*?<\/thinking>\s*/g, "");
|
|
143
|
+
if (text) {
|
|
144
|
+
callbacks.onText(text);
|
|
145
|
+
}
|
|
146
|
+
} else if (event.delta?.type === "input_json_delta" && currentToolId) {
|
|
147
|
+
toolInputs[currentToolId] = (toolInputs[currentToolId] || "") + event.delta.partial_json;
|
|
148
|
+
}
|
|
149
|
+
} else if (event.type === "content_block_stop" && currentToolId) {
|
|
150
|
+
try {
|
|
151
|
+
const inputJson = toolInputs[currentToolId];
|
|
152
|
+
if (inputJson) {
|
|
153
|
+
const input = JSON.parse(inputJson);
|
|
154
|
+
let inputDetail = "";
|
|
155
|
+
if (input.file_path) inputDetail = input.file_path;
|
|
156
|
+
else if (input.command) inputDetail = input.command;
|
|
157
|
+
else if (input.pattern) inputDetail = input.pattern;
|
|
158
|
+
if (inputDetail) {
|
|
159
|
+
callbacks.onActivity({
|
|
160
|
+
type: "tool_input",
|
|
161
|
+
id: currentToolId,
|
|
162
|
+
input: inputDetail
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
if (callbacks.onPlan) {
|
|
166
|
+
const toolName = toolNames[currentToolId] || "";
|
|
167
|
+
const plan = detectPlanFromTool(toolName, input);
|
|
168
|
+
if (plan) callbacks.onPlan(plan);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
}
|
|
173
|
+
currentToolId = null;
|
|
174
|
+
} else if (event.type === "result") {
|
|
175
|
+
const subtype = event.subtype || "complete";
|
|
176
|
+
callbacks.onActivity({
|
|
177
|
+
type: "all_complete",
|
|
178
|
+
status: subtype === "error" ? "error" : "complete"
|
|
179
|
+
});
|
|
180
|
+
} else if (event.type === "user") {
|
|
181
|
+
const toolResults = event.message?.content?.filter((c) => c.type === "tool_result") || [];
|
|
182
|
+
for (const result of toolResults) {
|
|
183
|
+
const toolId = result.tool_use_id;
|
|
184
|
+
const toolName = toolNames[toolId] || "tool";
|
|
185
|
+
const isError = result.is_error === true;
|
|
186
|
+
let output = "";
|
|
187
|
+
if (typeof result.content === "string") {
|
|
188
|
+
output = result.content.slice(0, 200);
|
|
189
|
+
} else if (Array.isArray(result.content)) {
|
|
190
|
+
const textContent = result.content.find((c) => c.type === "text");
|
|
191
|
+
output = textContent?.text?.slice(0, 200) || "";
|
|
192
|
+
}
|
|
193
|
+
callbacks.onActivity({
|
|
194
|
+
type: "tool_end",
|
|
195
|
+
tool: toolName,
|
|
196
|
+
id: toolId,
|
|
197
|
+
status: isError ? "error" : "complete",
|
|
198
|
+
output
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
console.log("Parse error for line:", line.slice(0, 100));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
claude.stderr.on("data", (data) => {
|
|
208
|
+
const text = data.toString();
|
|
209
|
+
console.error("Claude stderr:", text);
|
|
210
|
+
callbacks.onError(text);
|
|
211
|
+
});
|
|
212
|
+
const cleanupTempFiles = () => {
|
|
213
|
+
for (const p of tempImagePaths) {
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(p);
|
|
216
|
+
} catch {
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
const promise = new Promise((resolve) => {
|
|
221
|
+
claude.on("close", (code) => {
|
|
222
|
+
cleanupTempFiles();
|
|
223
|
+
callbacks.onComplete(code || 0);
|
|
224
|
+
resolve(code || 0);
|
|
225
|
+
});
|
|
226
|
+
claude.on("error", (err) => {
|
|
227
|
+
cleanupTempFiles();
|
|
228
|
+
callbacks.onError(err.message);
|
|
229
|
+
callbacks.onComplete(1);
|
|
230
|
+
resolve(1);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
return {
|
|
234
|
+
pid: claude.pid || 0,
|
|
235
|
+
kill: () => {
|
|
236
|
+
try {
|
|
237
|
+
claude.kill("SIGTERM");
|
|
238
|
+
} catch {
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
promise
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
export {
|
|
245
|
+
spawnClaude
|
|
246
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
class NotificationDispatcher {
|
|
2
|
+
channels = [];
|
|
3
|
+
register(channel) {
|
|
4
|
+
if (channel.isAvailable()) {
|
|
5
|
+
this.channels.push(channel);
|
|
6
|
+
console.log(`Notification channel registered: ${channel.name}`);
|
|
7
|
+
} else {
|
|
8
|
+
console.log(`Notification channel not available: ${channel.name}`);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
unregister(name) {
|
|
12
|
+
this.channels = this.channels.filter((c) => c.name !== name);
|
|
13
|
+
}
|
|
14
|
+
async dispatch(notification) {
|
|
15
|
+
const results = await Promise.allSettled(
|
|
16
|
+
this.channels.map((channel) => channel.send(notification))
|
|
17
|
+
);
|
|
18
|
+
for (let i = 0; i < results.length; i++) {
|
|
19
|
+
const result = results[i];
|
|
20
|
+
const channel = this.channels[i];
|
|
21
|
+
if (result.status === "rejected") {
|
|
22
|
+
console.error(`Notification failed on ${channel.name}:`, result.reason);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
getChannels() {
|
|
27
|
+
return this.channels.map((c) => c.name);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
let dispatcher = null;
|
|
31
|
+
function getNotificationDispatcher() {
|
|
32
|
+
if (!dispatcher) {
|
|
33
|
+
dispatcher = new NotificationDispatcher();
|
|
34
|
+
}
|
|
35
|
+
return dispatcher;
|
|
36
|
+
}
|
|
37
|
+
export {
|
|
38
|
+
NotificationDispatcher,
|
|
39
|
+
getNotificationDispatcher
|
|
40
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
function escapeAppleScript(str) {
|
|
3
|
+
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
4
|
+
}
|
|
5
|
+
class MacOSNotificationChannel {
|
|
6
|
+
name = "macos";
|
|
7
|
+
async send(notification) {
|
|
8
|
+
const title = escapeAppleScript(notification.title);
|
|
9
|
+
const body = escapeAppleScript(notification.body.slice(0, 200));
|
|
10
|
+
const sound = notification.type === "job_failed" ? "Basso" : "Glass";
|
|
11
|
+
const script = `display notification "${body}" with title "${title}" sound name "${sound}"`;
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const proc = spawn("osascript", ["-e", script]);
|
|
14
|
+
proc.on("close", (code) => resolve(code === 0));
|
|
15
|
+
proc.on("error", () => resolve(false));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
isAvailable() {
|
|
19
|
+
return process.platform === "darwin";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export {
|
|
23
|
+
MacOSNotificationChannel
|
|
24
|
+
};
|
|
File without changes
|
package/server/ssl.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
2
|
+
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import selfsigned from "selfsigned";
|
|
5
|
+
const TALKIE_DIR = join(homedir(), ".talkie");
|
|
6
|
+
const OLD_DIR = join(homedir(), ".talkboy");
|
|
7
|
+
const CERT_PATH = join(TALKIE_DIR, "cert.pem");
|
|
8
|
+
const KEY_PATH = join(TALKIE_DIR, "key.pem");
|
|
9
|
+
const TAILSCALE_CERT_PATH = join(TALKIE_DIR, "tailscale.crt");
|
|
10
|
+
const TAILSCALE_KEY_PATH = join(TALKIE_DIR, "tailscale.key");
|
|
11
|
+
function getSSLCerts() {
|
|
12
|
+
if (existsSync(OLD_DIR) && !existsSync(TALKIE_DIR)) {
|
|
13
|
+
renameSync(OLD_DIR, TALKIE_DIR);
|
|
14
|
+
}
|
|
15
|
+
if (!existsSync(TALKIE_DIR)) {
|
|
16
|
+
mkdirSync(TALKIE_DIR, { recursive: true });
|
|
17
|
+
}
|
|
18
|
+
if (existsSync(TAILSCALE_CERT_PATH) && existsSync(TAILSCALE_KEY_PATH)) {
|
|
19
|
+
console.log("Using Tailscale HTTPS certificates");
|
|
20
|
+
return {
|
|
21
|
+
cert: readFileSync(TAILSCALE_CERT_PATH, "utf-8"),
|
|
22
|
+
key: readFileSync(TAILSCALE_KEY_PATH, "utf-8"),
|
|
23
|
+
isTailscale: true
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
if (existsSync(CERT_PATH) && existsSync(KEY_PATH)) {
|
|
27
|
+
return {
|
|
28
|
+
cert: readFileSync(CERT_PATH, "utf-8"),
|
|
29
|
+
key: readFileSync(KEY_PATH, "utf-8")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
console.log("Generating self-signed SSL certificates...");
|
|
33
|
+
const attrs = [{ name: "commonName", value: "localhost" }];
|
|
34
|
+
const pems = selfsigned.generate(attrs, {
|
|
35
|
+
algorithm: "sha256",
|
|
36
|
+
days: 365,
|
|
37
|
+
keySize: 2048,
|
|
38
|
+
extensions: [
|
|
39
|
+
{
|
|
40
|
+
name: "subjectAltName",
|
|
41
|
+
altNames: [
|
|
42
|
+
{ type: 2, value: "localhost" },
|
|
43
|
+
{ type: 7, ip: "127.0.0.1" }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
});
|
|
48
|
+
writeFileSync(CERT_PATH, pems.cert);
|
|
49
|
+
writeFileSync(KEY_PATH, pems.private);
|
|
50
|
+
console.log(`SSL certificates saved to ${TALKIE_DIR}`);
|
|
51
|
+
return {
|
|
52
|
+
cert: pems.cert,
|
|
53
|
+
key: pems.private
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export {
|
|
57
|
+
getSSLCerts
|
|
58
|
+
};
|
package/server/state.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
let state = {
|
|
2
|
+
avatarState: "idle",
|
|
3
|
+
transcript: "",
|
|
4
|
+
lastUserMessage: "",
|
|
5
|
+
lastAssistantMessage: "",
|
|
6
|
+
messages: [],
|
|
7
|
+
claudeSessionId: null,
|
|
8
|
+
pendingMessage: null,
|
|
9
|
+
responseCallbacks: []
|
|
10
|
+
};
|
|
11
|
+
function updateState(update) {
|
|
12
|
+
state = { ...state, ...update };
|
|
13
|
+
}
|
|
14
|
+
function resetState() {
|
|
15
|
+
state = {
|
|
16
|
+
avatarState: "idle",
|
|
17
|
+
transcript: "",
|
|
18
|
+
lastUserMessage: "",
|
|
19
|
+
lastAssistantMessage: "",
|
|
20
|
+
messages: [],
|
|
21
|
+
claudeSessionId: null,
|
|
22
|
+
pendingMessage: null,
|
|
23
|
+
responseCallbacks: []
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export {
|
|
27
|
+
resetState,
|
|
28
|
+
state,
|
|
29
|
+
updateState
|
|
30
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { InlineKeyboard } from "grammy";
|
|
2
|
+
import * as conversations from "../db/repositories/conversations.js";
|
|
3
|
+
import * as telegramState from "../db/repositories/telegram.js";
|
|
4
|
+
const WEB_UI_URL = process.env.TALKIE_URL || "https://localhost:5173";
|
|
5
|
+
function setupCommands(bot) {
|
|
6
|
+
bot.command("start", async (ctx) => {
|
|
7
|
+
const userId = ctx.from?.id;
|
|
8
|
+
if (!userId) return;
|
|
9
|
+
await ctx.reply(
|
|
10
|
+
`Welcome to Talkie!
|
|
11
|
+
|
|
12
|
+
I'm your mobile interface to Claude Code conversations.
|
|
13
|
+
|
|
14
|
+
Commands:
|
|
15
|
+
/conversations - List recent conversations
|
|
16
|
+
/new <name> - Create new conversation
|
|
17
|
+
/current - Show current conversation
|
|
18
|
+
/status - Check what Claude is doing
|
|
19
|
+
/help - Show this message
|
|
20
|
+
|
|
21
|
+
Web UI: ${WEB_UI_URL}
|
|
22
|
+
|
|
23
|
+
Just send me a text message to chat with Claude!`
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
bot.command("help", async (ctx) => {
|
|
27
|
+
await ctx.reply(
|
|
28
|
+
`Talkie Commands:
|
|
29
|
+
|
|
30
|
+
/conversations - List recent conversations
|
|
31
|
+
/new <name> - Create new conversation
|
|
32
|
+
/current - Show current conversation
|
|
33
|
+
/status - Check what Claude is doing
|
|
34
|
+
/help - Show this message
|
|
35
|
+
|
|
36
|
+
Send any text message to continue your current conversation.`
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
bot.command("conversations", async (ctx) => {
|
|
40
|
+
const convos = conversations.listConversations(5, 0);
|
|
41
|
+
if (convos.length === 0) {
|
|
42
|
+
await ctx.reply("No conversations yet. Send a message or use /new to create one.");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const keyboard = new InlineKeyboard();
|
|
46
|
+
for (const conv of convos) {
|
|
47
|
+
const title = conv.title.length > 30 ? conv.title.slice(0, 30) + "..." : conv.title;
|
|
48
|
+
keyboard.text(title, `select_conv:${conv.id}`).row();
|
|
49
|
+
}
|
|
50
|
+
keyboard.text("+ Create new", "create_conv");
|
|
51
|
+
await ctx.reply("Select a conversation:", { reply_markup: keyboard });
|
|
52
|
+
});
|
|
53
|
+
bot.command("new", async (ctx) => {
|
|
54
|
+
const userId = ctx.from?.id;
|
|
55
|
+
if (!userId) return;
|
|
56
|
+
const name = ctx.match?.trim() || "New conversation";
|
|
57
|
+
const id = crypto.randomUUID();
|
|
58
|
+
const conv = conversations.createConversation({ id, title: name });
|
|
59
|
+
telegramState.setTelegramConversation(userId, conv.id);
|
|
60
|
+
await ctx.reply(`Created new conversation: "${conv.title}"
|
|
61
|
+
|
|
62
|
+
Send me a message to start chatting.`);
|
|
63
|
+
});
|
|
64
|
+
bot.command("current", async (ctx) => {
|
|
65
|
+
const userId = ctx.from?.id;
|
|
66
|
+
if (!userId) return;
|
|
67
|
+
const state = telegramState.getTelegramState(userId);
|
|
68
|
+
if (!state?.current_conversation_id) {
|
|
69
|
+
await ctx.reply("No conversation selected. Use /conversations to pick one or /new to create one.");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const conv = conversations.getConversation(state.current_conversation_id);
|
|
73
|
+
if (!conv) {
|
|
74
|
+
telegramState.setTelegramConversation(userId, null);
|
|
75
|
+
await ctx.reply("Current conversation no longer exists. Use /conversations to pick a new one.");
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const keyboard = new InlineKeyboard().text("Switch conversation", "switch_conv").text("Open in web", "open_web");
|
|
79
|
+
await ctx.reply(
|
|
80
|
+
`Current conversation: "${conv.title}"
|
|
81
|
+
|
|
82
|
+
Created: ${new Date(conv.created_at).toLocaleDateString()}
|
|
83
|
+
Last updated: ${new Date(conv.updated_at).toLocaleString()}`,
|
|
84
|
+
{ reply_markup: keyboard }
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
bot.command("status", async (ctx) => {
|
|
88
|
+
try {
|
|
89
|
+
const { Agent, fetch: undiciFetch } = await import("undici");
|
|
90
|
+
const response = await undiciFetch(`${WEB_UI_URL}/api/status`, {
|
|
91
|
+
dispatcher: new Agent({ connect: { rejectUnauthorized: false } })
|
|
92
|
+
});
|
|
93
|
+
const data = await response.json();
|
|
94
|
+
const stateEmoji = {
|
|
95
|
+
idle: "\u{1F634}",
|
|
96
|
+
listening: "\u{1F442}",
|
|
97
|
+
thinking: "\u{1F914}",
|
|
98
|
+
speaking: "\u{1F5E3}",
|
|
99
|
+
happy: "\u{1F60A}",
|
|
100
|
+
confused: "\u{1F615}"
|
|
101
|
+
};
|
|
102
|
+
await ctx.reply(
|
|
103
|
+
`Talkie Status:
|
|
104
|
+
|
|
105
|
+
Server: ${data.running ? "\u2705 Running" : "\u274C Stopped"}
|
|
106
|
+
Database: ${data.dbStatus === "connected" ? "\u2705 Connected" : "\u26A0\uFE0F Unavailable"}
|
|
107
|
+
Claude: ${stateEmoji[data.avatarState] || "\u2753"} ${data.avatarState}`
|
|
108
|
+
);
|
|
109
|
+
} catch {
|
|
110
|
+
await ctx.reply("Could not reach Talkie server. Is it running?");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
bot.callbackQuery(/^select_conv:(.+)$/, async (ctx) => {
|
|
114
|
+
const userId = ctx.from?.id;
|
|
115
|
+
if (!userId) return;
|
|
116
|
+
const convId = ctx.match[1];
|
|
117
|
+
const conv = conversations.getConversation(convId);
|
|
118
|
+
if (!conv) {
|
|
119
|
+
await ctx.answerCallbackQuery({ text: "Conversation not found" });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
telegramState.setTelegramConversation(userId, convId);
|
|
123
|
+
await ctx.answerCallbackQuery({ text: `Switched to: ${conv.title}` });
|
|
124
|
+
await ctx.editMessageText(`Selected: "${conv.title}"
|
|
125
|
+
|
|
126
|
+
Send me a message to continue chatting.`);
|
|
127
|
+
});
|
|
128
|
+
bot.callbackQuery("create_conv", async (ctx) => {
|
|
129
|
+
const userId = ctx.from?.id;
|
|
130
|
+
if (!userId) return;
|
|
131
|
+
const id = crypto.randomUUID();
|
|
132
|
+
const conv = conversations.createConversation({ id, title: "New conversation" });
|
|
133
|
+
telegramState.setTelegramConversation(userId, conv.id);
|
|
134
|
+
await ctx.answerCallbackQuery({ text: "Created new conversation" });
|
|
135
|
+
await ctx.editMessageText(`Created new conversation.
|
|
136
|
+
|
|
137
|
+
Send me a message to start chatting.`);
|
|
138
|
+
});
|
|
139
|
+
bot.callbackQuery("switch_conv", async (ctx) => {
|
|
140
|
+
const convos = conversations.listConversations(5, 0);
|
|
141
|
+
if (convos.length === 0) {
|
|
142
|
+
await ctx.answerCallbackQuery({ text: "No conversations available" });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const keyboard = new InlineKeyboard();
|
|
146
|
+
for (const conv of convos) {
|
|
147
|
+
const title = conv.title.length > 30 ? conv.title.slice(0, 30) + "..." : conv.title;
|
|
148
|
+
keyboard.text(title, `select_conv:${conv.id}`).row();
|
|
149
|
+
}
|
|
150
|
+
keyboard.text("+ Create new", "create_conv");
|
|
151
|
+
await ctx.answerCallbackQuery();
|
|
152
|
+
await ctx.editMessageText("Select a conversation:", { reply_markup: keyboard });
|
|
153
|
+
});
|
|
154
|
+
bot.callbackQuery("open_web", async (ctx) => {
|
|
155
|
+
await ctx.answerCallbackQuery({ text: `Open ${WEB_UI_URL} in your browser` });
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
export {
|
|
159
|
+
setupCommands
|
|
160
|
+
};
|