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:
pierreandLetta Code committed 2026-09-03 23:51:09 +03:00
1 parent dcf6d8df75
commit e212e3e120
2 files changed
+308 -6

No files matched your search

+148 -2
View File
@@ -53,6 +53,122 @@ fn http(alloc: std.mem.Allocator, io: Io, method: []const u8, url: []const u8, t
pub const Session = struct { token: []u8, user_id: []u8 }; // caller frees both
/// HTTP with a raw (non-JSON) body + explicit content type — media upload.
/// Returns body (allocated; caller frees).
fn httpRaw(alloc: std.mem.Allocator, io: Io, method: []const u8, url: []const u8, token: ?[]const u8, content_type: []const u8, file_path: ?[]const u8) ![]u8 {
// Arena for argv/header scratch — freed wholesale at scope exit (unlike
// http(), which leaks its header string per call).
var arena_state = std.heap.ArenaAllocator.init(alloc);
defer arena_state.deinit();
const a = arena_state.allocator();
var argv: std.ArrayList([]const u8) = .empty;
defer argv.deinit(a); // buffer lives in the arena — no-op free
try argv.appendSlice(a, &.{ "curl", "-sS", "-m", "120", "-X", method });
if (token) |t| {
const h = try std.fmt.allocPrint(a, "Authorization: Bearer {s}", .{t});
try argv.appendSlice(a, &.{ "-H", h });
}
if (content_type.len > 0) {
const ct = try std.fmt.allocPrint(a, "Content-Type: {s}", .{content_type});
try argv.appendSlice(a, &.{ "-H", ct });
}
if (file_path) |p| {
const d = try std.fmt.allocPrint(a, "@{s}", .{p});
try argv.appendSlice(a, &.{ "--data-binary", d });
}
try argv.append(a, url);
var child = try std.process.spawn(io, .{
.argv = argv.items,
.stdin = .ignore,
.stdout = .pipe,
.stderr = .ignore,
});
const out = child.stdout.?;
var buf: std.ArrayList(u8) = .empty;
errdefer buf.deinit(alloc);
var tmp: [16 * 1024]u8 = undefined;
while (true) {
const got = out.readStreaming(io, &.{&tmp}) catch break;
if (got == 0) break;
try buf.appendSlice(alloc, tmp[0..got]);
}
_ = child.wait(io) catch {};
return buf.toOwnedSlice(alloc);
}
/// Upload a local file to the homeserver media store.
/// Returns the mxc:// URI (allocated; caller frees).
pub fn uploadFile(alloc: std.mem.Allocator, io: Io, token: []const u8, path: []const u8, filename: []const u8, content_type: []const u8) ![]u8 {
const url = try std.fmt.allocPrint(alloc, "{s}/_matrix/media/v3/upload?filename={s}", .{ HOMESERVER, filename });
defer alloc.free(url);
const resp = try httpRaw(alloc, io, "POST", url, token, content_type, path);
defer alloc.free(resp);
const uri = letta.jsonStr(alloc, resp, 0, "content_uri") orelse {
std.debug.print("matrix: upload failed, resp: {s}\n", .{resp[0..@min(resp.len, 400)]});
return error.UploadFailed;
};
return uri.val;
}
/// Download media by mxc:// URI. Returns raw bytes (allocated; caller frees).
pub fn downloadFile(alloc: std.mem.Allocator, io: Io, token: []const u8, mxc: []const u8) ![]u8 {
// mxc://server/mediaId
if (!std.mem.startsWith(u8, mxc, "mxc://")) return error.BadMxc;
const rest = mxc["mxc://".len..];
const slash = std.mem.indexOfScalar(u8, rest, '/') orelse return error.BadMxc;
const server = rest[0..slash];
const media_id = rest[slash + 1 ..];
const url = try std.fmt.allocPrint(alloc, "{s}/_matrix/media/v3/download/{s}/{s}?allow_redirect=true", .{ HOMESERVER, server, media_id });
defer alloc.free(url);
const resp = try httpRaw(alloc, io, "GET", url, token, "", null);
// Error responses are small JSON with errcode — detect and report.
if (resp.len > 0 and resp[0] == '{') {
if (letta.jsonStr(alloc, resp, 0, "errcode") != null) {
std.debug.print("matrix: download failed, resp: {s}\n", .{resp[0..@min(resp.len, 400)]});
alloc.free(resp);
return error.DownloadFailed;
}
}
return resp;
}
/// Send a file event (m.file / m.image / m.audio / m.video).
/// Returns event id (allocated; caller frees).
pub fn sendFile(alloc: std.mem.Allocator, io: Io, token: []const u8, room: []const u8, msgtype: []const u8, mxc: []const u8, filename: []const u8, mimetype: []const u8, size: u64) ![]u8 {
const ts = Io.Clock.now(.real, io).nanoseconds;
const url = try std.fmt.allocPrint(alloc, "{s}/_matrix/client/v3/rooms/{s}/send/m.room.message/file-{d}", .{ HOMESERVER, room, ts });
defer alloc.free(url);
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try payload.appendSlice(alloc, "{\"msgtype\":\"");
try payload.appendSlice(alloc, msgtype);
try payload.appendSlice(alloc, "\",\"body\":\"");
var name_esc: std.ArrayList(u8) = .empty;
defer name_esc.deinit(alloc);
try jsonEscape(alloc, &name_esc, filename);
try payload.appendSlice(alloc, name_esc.items);
try payload.appendSlice(alloc, "\",\"url\":\"");
try payload.appendSlice(alloc, mxc);
try payload.appendSlice(alloc, "\",\"info\":{\"mimetype\":\"");
try payload.appendSlice(alloc, mimetype);
try payload.appendSlice(alloc, "\",\"size\":");
var sz_buf: [24]u8 = undefined;
const sz = std.fmt.bufPrint(&sz_buf, "{d}", .{size}) catch unreachable;
try payload.appendSlice(alloc, sz);
try payload.appendSlice(alloc, "}}");
const resp = try http(alloc, io, "PUT", url, token, payload.items);
defer alloc.free(resp);
const ev = letta.jsonStr(alloc, resp, 0, "event_id") orelse {
std.debug.print("matrix: sendFile failed, resp: {s}\n", .{resp[0..@min(resp.len, 400)]});
return error.SendFailed;
};
return ev.val;
}
pub fn login(alloc: std.mem.Allocator, io: Io, user: []const u8, password: []const u8) !Session {
const body = try std.fmt.allocPrint(alloc,
\\{{"type":"m.login.password","identifier":{{"type":"m.id.user","user":"{s}"}},"password":"{s}","initial_device_display_name":"{s}"}}
@@ -215,7 +331,15 @@ pub fn joinRoom(alloc: std.mem.Allocator, io: Io, token: []const u8, room: []con
alloc.free(resp);
}
pub const Event = struct { sender: []u8, body: []u8, ts: i64 = 0 }; // sender/body allocated
pub const Event = struct {
sender: []u8,
body: []u8,
ts: i64 = 0,
/// mxc:// URI when the message carries an attachment (m.file/m.image/…).
url: ?[]u8 = null, // allocated; caller frees
mimetype: []u8 = "", // allocated; caller frees
size: i64 = 0,
};
/// One sync poll. Returns message events + next `since` token. Caller frees.
/// (Sync first without a since token to establish one, then poll.)
@@ -312,12 +436,34 @@ pub fn sync(alloc: std.mem.Allocator, io: Io, token: []const u8, since: []const
else => 0,
} else 0;
// Attachment fields (m.file / m.image / m.audio / m.video).
var url_copy: ?[]u8 = null;
if (content.get("url")) |u| switch (u) {
.string => |v| url_copy = alloc.dupe(u8, v) catch null,
else => {},
};
var mime: []u8 = "";
var size: i64 = 0;
if (content.get("info")) |inf| switch (inf) {
.object => |io_| {
if (io_.get("mimetype")) |m| switch (m) {
.string => |v| mime = alloc.dupe(u8, v) catch "",
else => {},
};
if (io_.get("size")) |s| switch (s) {
.integer => |v| size = v,
else => {},
};
},
else => {},
};
const sender_copy = alloc.dupe(u8, sender_raw) catch continue;
const body_copy = alloc.dupe(u8, body_raw) catch {
alloc.free(sender_copy);
continue;
};
events.append(alloc, .{ .sender = sender_copy, .body = body_copy, .ts = ts }) catch {
events.append(alloc, .{ .sender = sender_copy, .body = body_copy, .ts = ts, .url = url_copy, .mimetype = mime, .size = size }) catch {
alloc.free(sender_copy);
alloc.free(body_copy);
};