@inetafrica/open-claudia 2.0.0 → 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/CHANGELOG.md +4 -0
- package/bin/cli.js +88 -0
- package/bot.js +9 -0
- package/channels/kazee/adapter.js +32 -10
- package/channels/telegram/adapter.js +38 -1
- package/core/access.js +21 -1
- package/core/context.js +14 -2
- package/core/loopback.js +123 -0
- package/core/router.js +10 -4
- package/core/runner.js +22 -3
- package/core/system-prompt.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v2.0.1
|
|
4
|
+
- Kazee owner detection: `envelope.channelId` is the chat-document id, but `KAZEE_OWNER_USER_ID` is the Kazee user id. `isChatOwner`/`isChatAuthorized` now also short-circuit when the inbound user id matches the configured transport owner, so the owner running `/auth` (or anything else) on a fresh Kazee install is recognized immediately instead of being queued as a non-owner request.
|
|
5
|
+
- `chatContext` now carries `userId` and `transport` in addition to `chatId`; new `currentUserId()` / `currentTransport()` exports.
|
|
6
|
+
|
|
3
7
|
## v2.0.0
|
|
4
8
|
- **Multi-channel**: the bot can now run on Telegram and Kazee Chat in the same process. `CHANNELS=telegram,kazee` selects which channels start up; each channel implements the new `ChannelAdapter` contract (send/edit/delete/upload/keyboard/typing/voice-fetch) and a `chatContext` AsyncLocalStorage routes replies, edits, and notifications back to the originating channel.
|
|
5
9
|
- Kazee adapter speaks V2 socket (inbound messages, edits, deletes, reactions, typing, presence) and uses REST for sending, editing, deleting, and uploads. Interactive button keyboards are sent as `type:"interactive"` with portable `interactive.buttons` so web and mobile clients render them natively.
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
}
|
package/core/access.js
CHANGED
|
@@ -5,7 +5,25 @@
|
|
|
5
5
|
// in the inbound envelope's canonicalUserId, not here.
|
|
6
6
|
|
|
7
7
|
const fs = require("fs");
|
|
8
|
-
const { AUTH_FILE, CHAT_ID, CHAT_IDS, saveEnvKey } = require("./config");
|
|
8
|
+
const { AUTH_FILE, CHAT_ID, CHAT_IDS, config, saveEnvKey } = require("./config");
|
|
9
|
+
const { currentTransport, currentUserId } = require("./context");
|
|
10
|
+
|
|
11
|
+
// Per-transport owner identifiers that don't live in CHAT_IDS or auth.json.
|
|
12
|
+
// Kazee envelopes carry the chat-document id as channelId, while the bot
|
|
13
|
+
// owner is identified by the Kazee user id. Resolve via env at call time so
|
|
14
|
+
// /channel add can update the value without a process restart.
|
|
15
|
+
function transportOwnerUserId(transport) {
|
|
16
|
+
if (transport === "kazee") return config.KAZEE_OWNER_USER_ID || "";
|
|
17
|
+
return "";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function matchesTransportOwner() {
|
|
21
|
+
const transport = currentTransport();
|
|
22
|
+
const userId = currentUserId();
|
|
23
|
+
if (!transport || !userId) return false;
|
|
24
|
+
const ownerUserId = transportOwnerUserId(transport);
|
|
25
|
+
return ownerUserId && String(userId) === String(ownerUserId);
|
|
26
|
+
}
|
|
9
27
|
|
|
10
28
|
function loadAuth() {
|
|
11
29
|
try {
|
|
@@ -24,6 +42,7 @@ function saveAuth(auth) {
|
|
|
24
42
|
}
|
|
25
43
|
|
|
26
44
|
function isChatAuthorized(chatId) {
|
|
45
|
+
if (matchesTransportOwner()) return true;
|
|
27
46
|
const id = String(chatId || "");
|
|
28
47
|
if (!id) return false;
|
|
29
48
|
if (CHAT_IDS.includes(id)) return true;
|
|
@@ -35,6 +54,7 @@ function isChatAuthorized(chatId) {
|
|
|
35
54
|
}
|
|
36
55
|
|
|
37
56
|
function isChatOwner(chatId) {
|
|
57
|
+
if (matchesTransportOwner()) return true;
|
|
38
58
|
const id = String(chatId || "");
|
|
39
59
|
if (!id) return false;
|
|
40
60
|
if (id === String(CHAT_ID || "")) return true;
|
package/core/context.js
CHANGED
|
@@ -6,8 +6,8 @@ const { AsyncLocalStorage } = require("node:async_hooks");
|
|
|
6
6
|
|
|
7
7
|
const chatContext = new AsyncLocalStorage();
|
|
8
8
|
|
|
9
|
-
function runInChat({ adapter, channelId, canonicalUserId, raw }, fn) {
|
|
10
|
-
return chatContext.run({ adapter, channelId, canonicalUserId, raw }, fn);
|
|
9
|
+
function runInChat({ adapter, channelId, canonicalUserId, userId, transport, raw }, fn) {
|
|
10
|
+
return chatContext.run({ adapter, channelId, canonicalUserId, userId, transport, raw }, fn);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
function currentStore() {
|
|
@@ -34,11 +34,23 @@ function currentRaw() {
|
|
|
34
34
|
return s ? s.raw : null;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
function currentUserId() {
|
|
38
|
+
const s = currentStore();
|
|
39
|
+
return s ? s.userId : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function currentTransport() {
|
|
43
|
+
const s = currentStore();
|
|
44
|
+
return s ? s.transport : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
37
47
|
module.exports = {
|
|
38
48
|
chatContext,
|
|
39
49
|
runInChat,
|
|
40
50
|
currentAdapter,
|
|
41
51
|
currentChannelId,
|
|
42
52
|
currentCanonicalUserId,
|
|
53
|
+
currentUserId,
|
|
54
|
+
currentTransport,
|
|
43
55
|
currentRaw,
|
|
44
56
|
};
|
package/core/loopback.js
ADDED
|
@@ -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
|
@@ -45,6 +45,8 @@ function scope(envelope, fn) {
|
|
|
45
45
|
adapter: envelope.adapter,
|
|
46
46
|
channelId: envelope.channelId,
|
|
47
47
|
canonicalUserId: envelope.canonicalUserId,
|
|
48
|
+
userId: envelope.userId,
|
|
49
|
+
transport: envelope.adapter?.type,
|
|
48
50
|
raw: envelope.raw,
|
|
49
51
|
}, fn);
|
|
50
52
|
}
|
|
@@ -143,11 +145,15 @@ async function handlePhoto(envelope) {
|
|
|
143
145
|
const state = currentState();
|
|
144
146
|
if (!state.currentSession) return send("Pick a project first.");
|
|
145
147
|
try {
|
|
146
|
-
const
|
|
147
|
-
if (!
|
|
148
|
-
const
|
|
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));
|
|
149
152
|
const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
|
|
150
|
-
|
|
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);
|
|
151
157
|
} catch (err) { await send(`Image failed: ${err.message}`); }
|
|
152
158
|
}
|
|
153
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:
|
|
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:
|
|
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:
|
|
399
|
+
env: subprocessEnv(),
|
|
381
400
|
stdio: ["ignore", "pipe", "pipe"],
|
|
382
401
|
detached: process.platform !== "win32",
|
|
383
402
|
});
|
package/core/system-prompt.js
CHANGED
|
@@ -45,7 +45,11 @@ ${soul}
|
|
|
45
45
|
${transcriptPointerNote(state)}
|
|
46
46
|
|
|
47
47
|
## Delivery
|
|
48
|
-
Reply normally in your final answer.
|
|
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