@inetafrica/open-claudia 2.0.1 → 2.0.2

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/bin/cli.js CHANGED
@@ -29,6 +29,82 @@ function startBot(botFile, args) {
29
29
  require(path.join(botDir, botFile));
30
30
  }
31
31
 
32
+ function sendViaLoopback(kind, restArgs) {
33
+ const http = require("http");
34
+ const filePath = restArgs[0];
35
+ if (!filePath) {
36
+ console.error(`Usage: open-claudia ${kind} <path>${kind === "send-voice" ? "" : " [caption]"}`);
37
+ process.exit(2);
38
+ }
39
+ const caption = restArgs.slice(1).join(" ");
40
+ if (!fs.existsSync(filePath)) {
41
+ console.error(`File not found: ${filePath}`);
42
+ process.exit(2);
43
+ }
44
+
45
+ const channelId = process.env.OC_CHANNEL_ID;
46
+ const adapterId = process.env.OC_CHANNEL_ADAPTER;
47
+ let sendUrl = process.env.OC_SEND_URL;
48
+ let sendToken = process.env.OC_SEND_TOKEN;
49
+
50
+ if (!sendUrl || !sendToken) {
51
+ const info = readLoopbackInfo();
52
+ if (info) { sendUrl = info.url; sendToken = info.token; }
53
+ }
54
+ if (!sendUrl || !sendToken || !channelId || !adapterId) {
55
+ console.error("No active chat context. This command only works from inside a running Claude task spawned by the bot.");
56
+ process.exit(2);
57
+ }
58
+
59
+ const u = new URL(sendUrl);
60
+ const fileName = path.basename(filePath);
61
+ const stat = fs.statSync(filePath);
62
+ const params = new URLSearchParams({ channelId, adapter: adapterId, fileName });
63
+ if (caption) params.set("caption", caption);
64
+
65
+ const req = http.request({
66
+ method: "POST",
67
+ hostname: u.hostname,
68
+ port: u.port,
69
+ path: `/${kind}?${params.toString()}`,
70
+ headers: {
71
+ Authorization: `Bearer ${sendToken}`,
72
+ "Content-Type": "application/octet-stream",
73
+ "Content-Length": stat.size,
74
+ },
75
+ }, (res) => {
76
+ let body = "";
77
+ res.on("data", (c) => { body += c; });
78
+ res.on("end", () => {
79
+ if (res.statusCode >= 200 && res.statusCode < 300) {
80
+ console.log(`Sent ${fileName} (${stat.size} bytes).`);
81
+ process.exit(0);
82
+ } else {
83
+ console.error(`Loopback ${res.statusCode}: ${body}`);
84
+ process.exit(1);
85
+ }
86
+ });
87
+ });
88
+ req.on("error", (e) => { console.error("Loopback request failed:", e.message); process.exit(1); });
89
+ fs.createReadStream(filePath).pipe(req);
90
+ }
91
+
92
+ function readLoopbackInfo() {
93
+ try {
94
+ const dir = path.join(configDir, "loopback");
95
+ if (!fs.existsSync(dir)) return null;
96
+ const entries = fs.readdirSync(dir).filter((f) => f.endsWith(".json"));
97
+ let newest = null;
98
+ for (const f of entries) {
99
+ const full = path.join(dir, f);
100
+ const stat = fs.statSync(full);
101
+ if (!newest || stat.mtimeMs > newest.mtimeMs) newest = { full, mtimeMs: stat.mtimeMs };
102
+ }
103
+ if (!newest) return null;
104
+ return JSON.parse(fs.readFileSync(newest.full, "utf-8"));
105
+ } catch (e) { return null; }
106
+ }
107
+
32
108
  function findBotProcesses() {
33
109
  try {
34
110
  if (process.platform === "win32") {
@@ -150,6 +226,13 @@ switch (command) {
150
226
  break;
151
227
  }
152
228
 
229
+ case "send-file":
230
+ case "send-photo":
231
+ case "send-voice": {
232
+ sendViaLoopback(command, args.slice(1));
233
+ break;
234
+ }
235
+
153
236
  default:
154
237
  console.log(`
155
238
  Open Claudia — AI Coding Assistant via Telegram
@@ -164,6 +247,11 @@ Commands:
164
247
  open-claudia health Run environment health checks
165
248
  open-claudia logs View recent logs
166
249
 
250
+ Send tools (only work inside an active bot-spawned task):
251
+ open-claudia send-file <path> [caption]
252
+ open-claudia send-photo <path> [caption]
253
+ open-claudia send-voice <path>
254
+
167
255
  Start options:
168
256
  --web Also start the web UI
169
257
  --quick Skip slow health checks (Claude auth, Telegram API)
package/bot.js CHANGED
@@ -17,6 +17,7 @@ const { initCrons } = require("./core/cron");
17
17
  const { onMessage, onAction } = require("./core/router");
18
18
  const { publicCommands } = require("./core/commands");
19
19
  const registry = require("./core/adapter-registry");
20
+ const loopback = require("./core/loopback");
20
21
  require("./core/handlers"); // side-effect: register slash commands
21
22
 
22
23
  const CURRENT_VERSION = require(path.join(__dirname, "package.json")).version;
@@ -42,6 +43,7 @@ async function gracefulShutdown(signal) {
42
43
  }
43
44
  }
44
45
  for (const a of adapters) { try { await a.stop(); } catch (e) {} }
46
+ try { loopback.stop(); } catch (e) {}
45
47
  try {
46
48
  const mediaDir = path.join(CONFIG_DIR, "media");
47
49
  if (fs.existsSync(mediaDir)) {
@@ -159,6 +161,13 @@ setInterval(checkForUpdates, 5 * 60 * 1000);
159
161
 
160
162
  initCrons();
161
163
 
164
+ try {
165
+ const lb = await loopback.start(registry);
166
+ console.log(`Loopback send endpoint: ${lb.url}`);
167
+ } catch (e) {
168
+ console.error("Loopback start failed:", e.message);
169
+ }
170
+
162
171
  for (const adapter of adapters) {
163
172
  try {
164
173
  await adapter.start();
@@ -121,13 +121,16 @@ class KazeeAdapter {
121
121
 
122
122
  const text = (msg.content || "").toString();
123
123
  const isCommand = text.trim().startsWith("/");
124
- const type = isCommand ? "command" : (msg.type === "voice" || msg.type === "audio" || msg.type === "image" || msg.type === "file" ? msg.type : "text");
124
+ // chat-central's message.type enum has no "voice" voice notes arrive
125
+ // as type "audio". Map the channel types we accept onto router types.
126
+ const KNOWN_TYPES = { voice: "voice", audio: "audio", image: "photo", video: "document", file: "document" };
127
+ const routerType = isCommand ? "command" : (KNOWN_TYPES[msg.type] || "text");
125
128
  const envelope = {
126
129
  adapter: this,
127
130
  channelId,
128
131
  canonicalUserId: canonicalForChannel("kazee", userId),
129
132
  userId,
130
- type: type === "image" ? "photo" : type === "file" ? "document" : type,
133
+ type: routerType,
131
134
  text,
132
135
  messageId: msg._id || msg.id,
133
136
  replyToId: msg.replyTo,
@@ -136,14 +139,28 @@ class KazeeAdapter {
136
139
  };
137
140
 
138
141
  if (msg.attachments && msg.attachments.length) {
139
- envelope.media = msg.attachments.map((a) => ({
140
- type: a.type || (a.mimeType?.startsWith("image/") ? "photo" : "document"),
141
- fileId: a.url || a._id,
142
- fileName: a.name || a.filename,
143
- mimeType: a.mimeType,
144
- size: a.size,
145
- }));
146
- if (envelope.type === "text") envelope.type = envelope.media[0].type === "image" ? "photo" : envelope.media[0].type;
142
+ envelope.media = msg.attachments.map((a) => {
143
+ let kind = a.type;
144
+ if (!kind) {
145
+ const mime = (a.mimeType || "").toLowerCase();
146
+ if (mime.startsWith("image/")) kind = "photo";
147
+ else if (mime.startsWith("audio/")) kind = "audio";
148
+ else if (mime.startsWith("video/")) kind = "video";
149
+ else kind = "document";
150
+ } else if (kind === "image") kind = "photo";
151
+ else if (kind === "file") kind = "document";
152
+ return {
153
+ type: kind,
154
+ fileId: a.url || a._id,
155
+ fileName: a.name || a.filename,
156
+ mimeType: a.mimeType,
157
+ size: a.size,
158
+ };
159
+ });
160
+ if (envelope.type === "text") {
161
+ const k = envelope.media[0].type;
162
+ envelope.type = k === "video" ? "document" : k;
163
+ }
147
164
  }
148
165
 
149
166
  this._emit("message", envelope);
@@ -203,6 +220,11 @@ class KazeeAdapter {
203
220
  return ok;
204
221
  }
205
222
 
223
+ async sendPhoto(channelId, filePath, caption) {
224
+ // Kazee's sendFile picks the right message type by extension already.
225
+ return this.sendFile(channelId, filePath, caption);
226
+ }
227
+
206
228
  async sendFile(channelId, filePath, caption) {
207
229
  try {
208
230
  const buffer = fs.readFileSync(filePath);
@@ -92,7 +92,34 @@ class TelegramAdapter {
92
92
  this.bot.on("audio", (msg) => this._emit("message", this._envelopeMedia(msg, "audio", msg.audio)));
93
93
  this.bot.on("photo", (msg) => {
94
94
  const photo = msg.photo[msg.photo.length - 1];
95
- this._emit("message", this._envelopeMedia(msg, "photo", photo));
95
+ if (!msg.media_group_id) {
96
+ this._emit("message", this._envelopeMedia(msg, "photo", photo));
97
+ return;
98
+ }
99
+ // Telegram delivers an album as N separate `photo` updates sharing
100
+ // the same media_group_id. Buffer briefly so the handler gets one
101
+ // envelope with media[] of all photos instead of N separate turns.
102
+ if (!this._photoGroups) this._photoGroups = new Map();
103
+ let batch = this._photoGroups.get(msg.media_group_id);
104
+ if (!batch) {
105
+ batch = { firstMsg: msg, items: [], timer: null };
106
+ this._photoGroups.set(msg.media_group_id, batch);
107
+ }
108
+ if (msg.caption && !batch.firstMsg.caption) batch.firstMsg.caption = msg.caption;
109
+ batch.items.push({
110
+ type: "photo",
111
+ fileId: photo.file_id,
112
+ fileName: photo.file_name,
113
+ mimeType: photo.mime_type,
114
+ size: photo.file_size,
115
+ });
116
+ if (batch.timer) clearTimeout(batch.timer);
117
+ batch.timer = setTimeout(() => {
118
+ this._photoGroups.delete(msg.media_group_id);
119
+ const env = this._envelopeMedia(batch.firstMsg, "photo", photo);
120
+ env.media = batch.items;
121
+ this._emit("message", env);
122
+ }, 600);
96
123
  });
97
124
  this.bot.on("document", (msg) => this._emit("message", this._envelopeMedia(msg, "document", msg.document)));
98
125
 
@@ -251,6 +278,16 @@ class TelegramAdapter {
251
278
  }
252
279
  }
253
280
 
281
+ async sendPhoto(channelId, filePath, caption) {
282
+ try {
283
+ await this.bot.sendPhoto(channelId, filePath, caption ? { caption } : {});
284
+ return true;
285
+ } catch (e) {
286
+ console.error("Send photo error:", e.message);
287
+ return false;
288
+ }
289
+ }
290
+
254
291
  async typing(channelId) {
255
292
  try { await this.bot.sendChatAction(channelId, "typing"); } catch (e) {}
256
293
  }
@@ -0,0 +1,123 @@
1
+ // Bot ↔ subprocess send channel. Each running bot publishes a tiny HTTP
2
+ // server on 127.0.0.1:<random-port> and writes {port, token} to a
3
+ // per-PID file under CONFIG_DIR/loopback. Subprocesses spawned through
4
+ // runClaude inherit OC_SEND_URL, OC_SEND_TOKEN, OC_CHANNEL_ID, and
5
+ // OC_CHANNEL_ADAPTER in their env; the `open-claudia send-file /
6
+ // send-voice / send-photo` CLI subcommands POST a file back through
7
+ // this endpoint so the active channel's adapter can deliver it.
8
+ //
9
+ // Bound to 127.0.0.1 only and gated by a random token so other local
10
+ // processes can't impersonate the bot.
11
+
12
+ const fs = require("fs");
13
+ const path = require("path");
14
+ const http = require("http");
15
+ const os = require("os");
16
+ const crypto = require("crypto");
17
+ const { CONFIG_DIR } = require("./config");
18
+
19
+ const INFO_DIR = path.join(CONFIG_DIR, "loopback");
20
+ const INFO_FILE = path.join(INFO_DIR, `${process.pid}.json`);
21
+
22
+ let server = null;
23
+ let token = null;
24
+ let registry = null;
25
+
26
+ function info() {
27
+ if (!server) return null;
28
+ const addr = server.address();
29
+ return { port: addr.port, token, pid: process.pid, url: `http://127.0.0.1:${addr.port}` };
30
+ }
31
+
32
+ function writeInfo() {
33
+ fs.mkdirSync(INFO_DIR, { recursive: true });
34
+ fs.writeFileSync(INFO_FILE, JSON.stringify(info(), null, 2), { mode: 0o600 });
35
+ }
36
+
37
+ function deleteInfo() {
38
+ try { fs.unlinkSync(INFO_FILE); } catch (e) {}
39
+ }
40
+
41
+ function reply(res, code, body) {
42
+ const data = JSON.stringify(body);
43
+ res.writeHead(code, { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) });
44
+ res.end(data);
45
+ }
46
+
47
+ function authOk(req) {
48
+ return (req.headers["authorization"] || "") === `Bearer ${token}`;
49
+ }
50
+
51
+ function readBodyToFile(req, dest) {
52
+ return new Promise((resolve, reject) => {
53
+ const out = fs.createWriteStream(dest);
54
+ req.pipe(out);
55
+ out.on("finish", () => resolve());
56
+ out.on("error", reject);
57
+ req.on("error", reject);
58
+ });
59
+ }
60
+
61
+ const KINDS = new Set(["send-file", "send-voice", "send-photo"]);
62
+
63
+ async function handle(req, res) {
64
+ try {
65
+ if (req.method !== "POST") return reply(res, 405, { error: "method not allowed" });
66
+ if (!authOk(req)) return reply(res, 401, { error: "unauthorized" });
67
+ const url = new URL(req.url, "http://127.0.0.1");
68
+ const kind = url.pathname.slice(1);
69
+ if (!KINDS.has(kind)) return reply(res, 404, { error: "not found" });
70
+ const channelId = url.searchParams.get("channelId");
71
+ const adapterId = url.searchParams.get("adapter");
72
+ const caption = url.searchParams.get("caption") || "";
73
+ const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
74
+ if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
75
+ const adapter = registry.findAdapter(adapterId);
76
+ if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
77
+
78
+ const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
79
+ const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
80
+ await readBodyToFile(req, tmp);
81
+
82
+ let ok = false;
83
+ try {
84
+ if (kind === "send-voice") {
85
+ ok = await adapter.sendVoice(channelId, tmp); // adapter unlinks on success/failure
86
+ } else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
87
+ ok = await adapter.sendPhoto(channelId, tmp, caption);
88
+ } else {
89
+ ok = await adapter.sendFile(channelId, tmp, caption);
90
+ }
91
+ } finally {
92
+ if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
93
+ }
94
+ return reply(res, ok ? 200 : 500, { ok });
95
+ } catch (e) {
96
+ console.error("loopback handle error:", e.message);
97
+ return reply(res, 500, { error: e.message });
98
+ }
99
+ }
100
+
101
+ function start(adapterRegistry) {
102
+ if (server) return Promise.resolve(info());
103
+ registry = adapterRegistry;
104
+ token = crypto.randomBytes(24).toString("hex");
105
+ server = http.createServer(handle);
106
+ return new Promise((resolve, reject) => {
107
+ server.once("error", reject);
108
+ server.listen(0, "127.0.0.1", () => {
109
+ writeInfo();
110
+ resolve(info());
111
+ });
112
+ });
113
+ }
114
+
115
+ function stop() {
116
+ if (server) try { server.close(); } catch (e) {}
117
+ server = null;
118
+ deleteInfo();
119
+ }
120
+
121
+ process.on("exit", deleteInfo);
122
+
123
+ module.exports = { start, stop, info, INFO_DIR, INFO_FILE };
package/core/router.js CHANGED
@@ -145,11 +145,15 @@ async function handlePhoto(envelope) {
145
145
  const state = currentState();
146
146
  if (!state.currentSession) return send("Pick a project first.");
147
147
  try {
148
- const media = envelope.media?.[0];
149
- if (!media) return;
150
- const p = await envelope.adapter.downloadMedia(media);
148
+ const items = envelope.media || [];
149
+ if (!items.length) return;
150
+ const paths = [];
151
+ for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
151
152
  const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
152
- await runClaude(`Image at ${p}\n\nView it, then: ${caption}`, state.currentSession.dir, envelope.messageId);
153
+ const prompt = paths.length === 1
154
+ ? `Image at ${paths[0]}\n\nView it, then: ${caption}`
155
+ : `Images:\n${paths.map((p, i) => `${i + 1}. ${p}`).join("\n")}\n\nView all ${paths.length}, then: ${caption}`;
156
+ await runClaude(prompt, state.currentSession.dir, envelope.messageId);
153
157
  } catch (err) { await send(`Image failed: ${err.message}`); }
154
158
  }
155
159
 
package/core/runner.js CHANGED
@@ -20,6 +20,25 @@ const {
20
20
  promptWithTranscriptPointer, stripTranscriptPointerForStorage,
21
21
  } = require("./transcripts");
22
22
  const { getClaudeOAuthToken, claudeAuthRecoveryMessage, isClaudeAuthErrorText, claudeUsageLimitMessage, isClaudeUsageLimitText, runClaudeAuthStatusDiagnostic, claudeSubprocessEnv } = require("./auth-flow");
23
+ const loopback = require("./loopback");
24
+
25
+ function chatEnvOverlay() {
26
+ const adapter = currentAdapter();
27
+ const channelId = currentChannelId();
28
+ const lb = loopback.info();
29
+ const overlay = {};
30
+ if (adapter && adapter.id) overlay.OC_CHANNEL_ADAPTER = adapter.id;
31
+ if (channelId) overlay.OC_CHANNEL_ID = String(channelId);
32
+ if (lb) {
33
+ overlay.OC_SEND_URL = lb.url;
34
+ overlay.OC_SEND_TOKEN = lb.token;
35
+ }
36
+ return overlay;
37
+ }
38
+
39
+ function subprocessEnv() {
40
+ return { ...claudeSubprocessEnv(), ...chatEnvOverlay() };
41
+ }
23
42
 
24
43
  function parseStreamEvents(data) {
25
44
  const events = [];
@@ -126,7 +145,7 @@ function preflightClaudeAuthMessage() {
126
145
  const { execSync } = require("child_process");
127
146
  const output = execSync(`"${CLAUDE_PATH}" auth status`, {
128
147
  cwd: process.env.HOME || require("os").homedir(),
129
- env: claudeSubprocessEnv(),
148
+ env: subprocessEnv(),
130
149
  encoding: "utf8",
131
150
  timeout: 10000,
132
151
  stdio: ["ignore", "pipe", "pipe"],
@@ -218,7 +237,7 @@ async function runClaudeCapture(prompt, cwd, opts = {}) {
218
237
  const args = buildClaudeArgs(prompt, { ...opts, skipAutoCompact: true });
219
238
  const proc = spawn(getActiveBinary(), args, {
220
239
  cwd,
221
- env: claudeSubprocessEnv(),
240
+ env: subprocessEnv(),
222
241
  stdio: ["ignore", "pipe", "pipe"],
223
242
  detached: process.platform !== "win32",
224
243
  });
@@ -377,7 +396,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
377
396
  const binaryPath = getActiveBinary();
378
397
  const proc = spawn(binaryPath, args, {
379
398
  cwd,
380
- env: claudeSubprocessEnv(),
399
+ env: subprocessEnv(),
381
400
  stdio: ["ignore", "pipe", "pipe"],
382
401
  detached: process.platform !== "win32",
383
402
  });
@@ -45,7 +45,11 @@ ${soul}
45
45
  ${transcriptPointerNote(state)}
46
46
 
47
47
  ## Delivery
48
- Reply normally in your final answer. If you must send a large file, image, or artifact directly, use the channel API/CLI; never print or embed bot tokens in prompts, commands, logs, or messages.
48
+ Reply normally in your final answer. To send a file, image, or voice clip back to the current chat, run the bot CLI from inside this task channel context is already in the env:
49
+ - \`open-claudia send-file <path> [caption]\` — any document/binary
50
+ - \`open-claudia send-photo <path> [caption]\` — image with inline preview
51
+ - \`open-claudia send-voice <path>\` — ogg/opus voice note
52
+ Never print or embed bot tokens in prompts, commands, logs, or messages.
49
53
 
50
54
  ## Guidelines
51
55
  - Keep responses concise — many users are on mobile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.0.1",
3
+ "version": "2.0.2",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {