matrix: file transfer both ways — send-file one-shot (upload + m.image/m.file by ext), incoming attachments downloaded to /tmp/matrix-files and path forwarded to agent
👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code <noreply@letta.com>
This commit is contained in:
1 parent
dcf6d8df75
commit
e212e3e120
2 files changed
+308
-6
No files matched your search
+160
-4
@@ -39,6 +39,115 @@ fn runSend(alloc: std.mem.Allocator, io: std.Io, text: []const u8) !void {
|
||||
alloc.free(ev_id);
|
||||
}
|
||||
|
||||
/// Infer msgtype + mimetype from filename extension.
|
||||
const FileType = struct { msgtype: []const u8, mimetype: []const u8 };
|
||||
fn fileTypeOf(path: []const u8) FileType {
|
||||
const ext = if (std.mem.lastIndexOfScalar(u8, path, '.')) |i| path[i + 1 ..] else "";
|
||||
const map = .{
|
||||
.{ ".png", "m.image", "image/png" },
|
||||
.{ ".jpg", "m.image", "image/jpeg" },
|
||||
.{ ".jpeg", "m.image", "image/jpeg" },
|
||||
.{ ".webp", "m.image", "image/webp" },
|
||||
.{ ".gif", "m.image", "image/gif" },
|
||||
.{ ".svg", "m.image", "image/svg+xml" },
|
||||
.{ ".mp3", "m.audio", "audio/mpeg" },
|
||||
.{ ".ogg", "m.audio", "audio/ogg" },
|
||||
.{ ".wav", "m.audio", "audio/wav" },
|
||||
.{ ".mp4", "m.video", "video/mp4" },
|
||||
.{ ".webm", "m.video", "video/webm" },
|
||||
.{ ".pdf", "m.file", "application/pdf" },
|
||||
.{ ".txt", "m.file", "text/plain" },
|
||||
.{ ".md", "m.file", "text/markdown" },
|
||||
.{ ".json", "m.file", "application/json" },
|
||||
.{ ".zip", "m.file", "application/zip" },
|
||||
.{ ".gz", "m.file", "application/gzip" },
|
||||
.{ ".log", "m.file", "text/plain" },
|
||||
};
|
||||
inline for (map) |e| {
|
||||
if (std.ascii.eqlIgnoreCase(ext, e[0][1..])) return .{ .msgtype = e[1], .mimetype = e[2] };
|
||||
}
|
||||
return .{ .msgtype = "m.file", .mimetype = "application/octet-stream" };
|
||||
}
|
||||
|
||||
/// Basename of a path.
|
||||
fn basenameOf(path: []const u8) []const u8 {
|
||||
if (std.mem.lastIndexOfScalar(u8, path, '/')) |i| return path[i + 1 ..];
|
||||
return path;
|
||||
}
|
||||
|
||||
/// One-shot file send: `matrix_harness send-file /path/to/file`.
|
||||
/// Uploads to the homeserver media store, then sends as m.image/m.file.
|
||||
fn runSendFile(alloc: std.mem.Allocator, io: std.Io, path: []const u8) !void {
|
||||
letta.initConversationsDir();
|
||||
const user = env_or("MATRIX_USER", "@chaos-prime:chaosmith.systems");
|
||||
const password = env_or("MATRIX_PASSWORD", "");
|
||||
const room = env_or("MATRIX_ROOM", "");
|
||||
if (password.len == 0 or room.len == 0) {
|
||||
std.debug.print("matrix_harness send-file: MATRIX_PASSWORD and MATRIX_ROOM required\n", .{});
|
||||
return error.NoCredentials;
|
||||
}
|
||||
const dir = std.Io.Dir.cwd();
|
||||
const st = dir.statFile(io, path, .{}) catch {
|
||||
std.debug.print("matrix_harness send-file: cannot stat {s}\n", .{path});
|
||||
return error.NoFile;
|
||||
};
|
||||
const size: u64 = switch (st.kind) {
|
||||
.file => st.size,
|
||||
else => return error.NotAFile,
|
||||
};
|
||||
|
||||
const ft = fileTypeOf(path);
|
||||
const name = basenameOf(path);
|
||||
std.debug.print("matrix_harness: uploading {s} ({d} bytes, {s})...\n", .{ name, size, ft.mimetype });
|
||||
const session = try matrix.login(alloc, io, user, password);
|
||||
defer alloc.free(session.token);
|
||||
defer alloc.free(session.user_id);
|
||||
const mxc = try matrix.uploadFile(alloc, io, session.token, path, name, ft.mimetype);
|
||||
defer alloc.free(mxc);
|
||||
const ev_id = try matrix.sendFile(alloc, io, session.token, room, ft.msgtype, mxc, name, ft.mimetype, size);
|
||||
std.debug.print("matrix_harness: sent {s} ({s}) as {s}\n", .{ ev_id, name, ft.msgtype });
|
||||
alloc.free(ev_id);
|
||||
}
|
||||
|
||||
/// Where incoming peer attachments are saved. The harness passes this path
|
||||
/// to the agent in the forwarded message.
|
||||
const FILES_DIR = "/tmp/matrix-files";
|
||||
|
||||
/// Sanitize a filename for local saving (keep it recognizable, drop path
|
||||
/// separators and control chars).
|
||||
fn sanitize(alloc: std.mem.Allocator, name: []const u8) ![]u8 {
|
||||
var out: std.ArrayList(u8) = .empty;
|
||||
errdefer out.deinit(alloc);
|
||||
for (name) |c| {
|
||||
switch (c) {
|
||||
'/', '\\', 0...0x1f, 0x7f => try out.append(alloc, '_'),
|
||||
' ' => try out.append(alloc, '_'),
|
||||
else => try out.append(alloc, c),
|
||||
}
|
||||
}
|
||||
if (out.items.len == 0) try out.appendSlice(alloc, "attachment");
|
||||
return out.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
/// Download an incoming attachment to FILES_DIR. Returns local path
|
||||
/// (allocated; caller frees).
|
||||
fn saveAttachment(alloc: std.mem.Allocator, io: std.Io, token: []const u8, ev_ts: i64, filename: []const u8, mxc: []const u8) ![]u8 {
|
||||
std.Io.Dir.cwd().createDirPath(io, FILES_DIR) catch {};
|
||||
const safe = try sanitize(alloc, filename);
|
||||
defer alloc.free(safe);
|
||||
const path = try std.fmt.allocPrint(alloc, "{s}/{d}-{s}", .{ FILES_DIR, if (ev_ts > 0) ev_ts else 0, safe });
|
||||
errdefer alloc.free(path);
|
||||
const data = try matrix.downloadFile(alloc, io, token, mxc);
|
||||
defer alloc.free(data);
|
||||
const f = try std.Io.Dir.createFileAbsolute(io, path, .{ .truncate = true });
|
||||
defer f.close(io);
|
||||
var wbuf: [16 * 1024]u8 = undefined;
|
||||
var w = f.writer(io, &wbuf);
|
||||
try w.interface.writeAll(data);
|
||||
try w.interface.flush();
|
||||
return path;
|
||||
}
|
||||
|
||||
/// Persistent seen-state: last homeserver event timestamp we already
|
||||
/// forwarded. Survives restarts so backlog never re-sends read messages.
|
||||
const STATE_FILE = "/tmp/matrix-harness.state";
|
||||
@@ -80,6 +189,13 @@ pub fn main(init: std.process.Init) !void {
|
||||
std.debug.print("usage: matrix_harness send \"message\"\n", .{});
|
||||
return error.MissingMessage;
|
||||
}
|
||||
if (first_arg != null and std.mem.eql(u8, first_arg.?, "send-file")) {
|
||||
if (args_it.next()) |p| {
|
||||
return runSendFile(alloc, io, p);
|
||||
}
|
||||
std.debug.print("usage: matrix_harness send-file /path/to/file\n", .{});
|
||||
return error.MissingFile;
|
||||
}
|
||||
}
|
||||
|
||||
letta.initConversationsDir();
|
||||
@@ -131,8 +247,20 @@ pub fn main(init: std.process.Init) !void {
|
||||
for (first.events) |ev| {
|
||||
defer alloc.free(ev.sender);
|
||||
defer alloc.free(ev.body);
|
||||
defer if (ev.url) |u| alloc.free(u);
|
||||
defer if (ev.mimetype.len > 0) alloc.free(ev.mimetype);
|
||||
if (ev.ts > new_max) new_max = ev.ts;
|
||||
if (!std.mem.eql(u8, ev.sender, peer)) continue;
|
||||
// Attachments in backlog are stale — note them but don't download.
|
||||
if (ev.url != null) {
|
||||
if (ev.ts > 0 and now_ms - ev.ts > 30 * 60 * 1000) continue;
|
||||
if (ev.ts > 0 and ev.ts <= seen_ts) continue;
|
||||
try backlog.appendSlice(alloc, ev.sender);
|
||||
try backlog.appendSlice(alloc, ": [sent a file: ");
|
||||
try backlog.appendSlice(alloc, ev.body);
|
||||
try backlog.appendSlice(alloc, " — not downloaded, ask if needed]\n");
|
||||
continue;
|
||||
}
|
||||
if (ev.body.len == 0) continue;
|
||||
if (ev.ts > 0 and now_ms - ev.ts > 30 * 60 * 1000) continue; // stale
|
||||
if (ev.ts > 0 and ev.ts <= seen_ts) continue; // already seen/answered
|
||||
@@ -175,15 +303,13 @@ pub fn main(init: std.process.Init) !void {
|
||||
for (result.events) |ev| {
|
||||
defer alloc.free(ev.sender);
|
||||
defer alloc.free(ev.body);
|
||||
defer if (ev.url) |u| alloc.free(u);
|
||||
defer if (ev.mimetype.len > 0) alloc.free(ev.mimetype);
|
||||
defer if (ev.ts > 0) saveSeenTs(io, ev.ts);
|
||||
// Only peer messages (skip own sends and server noise).
|
||||
if (!std.mem.eql(u8, ev.sender, peer)) continue;
|
||||
if (ev.body.len == 0) continue;
|
||||
|
||||
std.debug.print("matrix_harness: [{s}] {s}\n", .{ ev.sender, ev.body });
|
||||
|
||||
// Prefix with origin + event UTC timestamp so the agent can tell
|
||||
// Matrix-origin messages from CLI ones (and when they were sent).
|
||||
var ts_buf: [16]u8 = undefined;
|
||||
const ts_str: []const u8 = blk: {
|
||||
if (ev.ts <= 0) break :blk "";
|
||||
@@ -192,6 +318,36 @@ pub fn main(init: std.process.Init) !void {
|
||||
const sod = epoch.getDaySeconds();
|
||||
break :blk std.fmt.bufPrint(&ts_buf, " {d:0>2}:{d:0>2}Z", .{ sod.getHoursIntoDay(), sod.getMinutesIntoHour() }) catch "";
|
||||
};
|
||||
|
||||
// Attachment: download locally and forward the path to the agent.
|
||||
if (ev.url) |mxc| {
|
||||
const local = saveAttachment(alloc, io, session.token, ev.ts, ev.body, mxc) catch |e| blk: {
|
||||
std.debug.print("matrix_harness: attachment download failed: {s}\n", .{@errorName(e)});
|
||||
break :blk null;
|
||||
};
|
||||
defer if (local) |p| alloc.free(p);
|
||||
var size_buf: [24]u8 = undefined;
|
||||
const size_str = std.fmt.bufPrint(&size_buf, "{d}", .{ev.size}) catch "?";
|
||||
const note = if (local) |p|
|
||||
try std.fmt.allocPrint(alloc, "[matrix{s}] sent a file: \"{s}\" ({s}, {s} bytes) — saved to {s}", .{ ts_str, ev.body, ev.mimetype, size_str, p })
|
||||
else
|
||||
try std.fmt.allocPrint(alloc, "[matrix{s}] sent a file: \"{s}\" ({s}, {s} bytes) — download failed, ask them to resend", .{ ts_str, ev.body, ev.mimetype, size_str });
|
||||
defer alloc.free(note);
|
||||
const reply = letta.infer(io, alloc, agent, note) catch |e| {
|
||||
std.debug.print("matrix_harness: letta failed: {s}\n", .{@errorName(e)});
|
||||
continue;
|
||||
};
|
||||
defer alloc.free(reply);
|
||||
if (matrix.sendText(alloc, io, session.token, room_id, reply)) |ev_id| alloc.free(ev_id) else |e| {
|
||||
std.debug.print("matrix_harness: send failed: {s}\n", .{@errorName(e)});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
std.debug.print("matrix_harness: [{s}] {s}\n", .{ ev.sender, ev.body });
|
||||
|
||||
// Prefix with origin + event UTC timestamp so the agent can tell
|
||||
// Matrix-origin messages from CLI ones (and when they were sent).
|
||||
const prefixed = try std.fmt.allocPrint(alloc, "[matrix{s}] {s}", .{ ts_str, ev.body });
|
||||
defer alloc.free(prefixed);
|
||||
|
||||
|
||||
Reference in new issue
Block a user