diff --git a/build.zig b/build.zig index baf71479ba..4fcdd10091 100644 --- a/build.zig +++ b/build.zig @@ -177,6 +177,19 @@ pub fn build(b: *std.Build) void { const run_vm_tests = b.addRunArtifact(vm_tests); test_step.dependOn(&run_vm_tests.step); + // E2E + Benchmarks + Verdict tests (Phase 4) + const e2e_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/e2e_test.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_e2e_tests = b.addRunArtifact(e2e_tests); + test_step.dependOn(&run_e2e_tests.step); + const e2e_step = b.step("e2e", "Run E2E tests + benchmarks + verdict"); + e2e_step.dependOn(&run_e2e_tests.step); + // C API tests (libtrinity-vsa) const c_api_tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -1337,6 +1350,22 @@ pub fn build(b: *std.Build) void { }); b.installArtifact(ralph_hook); + // TRI BOT — Telegram bot as Claude Code CLI remote control + const tri_bot = b.addExecutable(.{ + .name = "tri-bot", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/mcp/trinity_mcp/bot/main.zig"), + .target = target, + .optimize = optimize, + }), + }); + b.installArtifact(tri_bot); + + const run_tri_bot = b.addRunArtifact(tri_bot); + if (b.args) |args| run_tri_bot.addArgs(args); + const tri_bot_step = b.step("tri-bot", "Run TRI BOT \xe2\x80\x94 Telegram Claude Code remote control"); + tri_bot_step.dependOn(&run_tri_bot.step); + // ═══════════════════════════════════════════════════════════════════════════ // PHI LOOP — 999 Links of Cosmic Consciousness Gene const phi_loop = b.addExecutable(.{ diff --git a/specs/tri/tri_bot.vibee b/specs/tri/tri_bot.vibee new file mode 100644 index 0000000000..22ddf8679d --- /dev/null +++ b/specs/tri/tri_bot.vibee @@ -0,0 +1,88 @@ +name: tri_bot +version: "1.0.0" +language: zig +module: tri_bot + +description: | + TRI BOT — Telegram bot as Claude Code CLI remote control. + Receives commands via getUpdates long polling, dispatches to claude CLI, + streams responses back via sendMessage / sendMessageDraft. + +constants: + POLL_TIMEOUT: 30 + MAX_MESSAGE_LEN: 4096 + MAX_RESPONSE_BUF: 1048576 + STREAM_CHUNK_INTERVAL_MS: 500 + +types: + BotConfig: + fields: + bot_token: String + chat_id: String + project_root: String + max_turns: Int + model: Option + + BotState: + fields: + last_update_id: Int + current_session_id: Option + current_model: Option + is_busy: Bool + + TelegramUpdate: + fields: + update_id: Int + chat_id: Int + text: String + + Command: + fields: + name: String + args: String + +behaviors: + - name: poll_updates + given: A valid BotConfig with bot_token + when: Bot polls Telegram getUpdates with timeout and offset + then: Returns list of TelegramUpdate or empty on timeout + + - name: parse_command + given: A TelegramUpdate with text starting with / + when: Text is parsed for command name and arguments + then: Returns a Command struct with name and args separated + + - name: dispatch_command + given: A parsed Command and current BotState + when: Command is matched against known commands + then: Executes the appropriate handler and returns response + + - name: handle_ask + given: Command /ask with a question as args + when: Spawns claude -p with the question + then: Sends Claude response to Telegram + + - name: handle_continue + given: Command /continue with optional question + when: Spawns claude -p with --continue flag + then: Sends Claude response continuing previous session + + - name: handle_status + given: Command /status with no args + when: Spawns claude -p with status query + then: Sends formatted project status to Telegram + + - name: handle_stop + given: Command /stop while a Claude process is running + when: Kills the active child process + then: Sends confirmation message + + - name: handle_help + given: Command /help + when: Bot receives help request + then: Sends list of available commands + + - name: run_bot_loop + given: Initialized BotConfig and BotState + when: Main loop starts + then: Polls updates, parses commands, dispatches handlers, repeats diff --git a/tools/mcp/trinity_mcp/bot/bot_loop.zig b/tools/mcp/trinity_mcp/bot/bot_loop.zig new file mode 100644 index 0000000000..c5c9f03915 --- /dev/null +++ b/tools/mcp/trinity_mcp/bot/bot_loop.zig @@ -0,0 +1,122 @@ +// bot_loop.zig — Main poll → parse → dispatch → repeat loop +const std = @import("std"); +const telegram_api = @import("telegram_api.zig"); +const json_utils = @import("json_utils.zig"); +const command_parser = @import("command_parser.zig"); +const handlers = @import("handlers.zig"); + +const BotConfig = telegram_api.BotConfig; + +/// Run the bot loop: poll Telegram, parse commands, dispatch handlers. +/// Never returns (infinite loop). +pub fn run(allocator: std.mem.Allocator, config: BotConfig) void { + var last_update_id: i64 = 0; + + // Announce startup + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 TRI BOT online! Send /help for commands."); + + std.debug.print("[tri-bot] Started. Polling Telegram...\n", .{}); + + while (true) { + const body = telegram_api.getUpdates(allocator, config.bot_token, last_update_id + 1) orelse { + // Network error — wait and retry + std.Thread.sleep(5 * std.time.ns_per_s); + continue; + }; + defer allocator.free(body); + + // Process each update + const Context = struct { + allocator: std.mem.Allocator, + config: BotConfig, + max_id: i64, + }; + var ctx = Context{ + .allocator = allocator, + .config = config, + .max_id = last_update_id, + }; + + // We can't use closures in Zig, so use a global-style dispatch. + // Instead, manually iterate updates with a simple loop. + processUpdates(allocator, config, body, &ctx.max_id); + last_update_id = ctx.max_id; + } +} + +fn processUpdates(allocator: std.mem.Allocator, config: BotConfig, body: []const u8, max_id: *i64) void { + // Find each "update_id": block manually + var pos: usize = 0; + while (pos < body.len) { + const needle = "\"update_id\":"; + const idx = std.mem.indexOfPos(u8, body, pos, needle) orelse break; + + // Determine block boundary (next update_id or end) + const next_idx = std.mem.indexOfPos(u8, body, idx + needle.len + 1, needle) orelse body.len; + const block = body[idx..next_idx]; + + // Extract update_id + const uid = json_utils.extractInt(block, "update_id") orelse { + pos = idx + needle.len; + continue; + }; + + // Update max + if (uid > max_id.*) { + max_id.* = uid; + } + + // Extract chat_id + const chat_id_val = blk: { + const chat_needle = "\"chat\":{\"id\":"; + const ci = std.mem.indexOf(u8, block, chat_needle) orelse break :blk @as(i64, 0); + const cs = ci + chat_needle.len; + var ce = cs; + while (ce < block.len and ((block[ce] >= '0' and block[ce] <= '9') or block[ce] == '-')) : (ce += 1) {} + break :blk std.fmt.parseInt(i64, block[cs..ce], 10) catch 0; + }; + + // Auth check: only respond to configured chat_id + const expected_chat_id = std.fmt.parseInt(i64, config.chat_id, 10) catch 0; + if (chat_id_val != expected_chat_id) { + std.debug.print("[tri-bot] Ignoring update from chat {d} (expected {d})\n", .{ chat_id_val, expected_chat_id }); + pos = next_idx; + continue; + } + + // Extract text + const text = json_utils.extractString(block, "text") orelse { + pos = next_idx; + continue; + }; + + std.debug.print("[tri-bot] Update {d}: \"{s}\"\n", .{ uid, text }); + + // Parse command + const cmd = command_parser.parse(text); + + // Dispatch + dispatch(allocator, config, cmd); + + pos = next_idx; + } +} + +fn dispatch(allocator: std.mem.Allocator, config: BotConfig, cmd: command_parser.Command) void { + if (std.mem.eql(u8, cmd.name, "help")) { + handlers.handleHelp(allocator, config); + } else if (std.mem.eql(u8, cmd.name, "ask")) { + handlers.handleAsk(allocator, config, cmd.args); + } else if (std.mem.eql(u8, cmd.name, "continue")) { + handlers.handleContinue(allocator, config, cmd.args); + } else if (std.mem.eql(u8, cmd.name, "status")) { + handlers.handleStatus(allocator, config); + } else if (std.mem.eql(u8, cmd.name, "stop")) { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9b\x94 /stop not yet implemented (Phase 2)"); + } else if (cmd.name.len > 0) { + // Unknown command + var buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9d\x93 Unknown command: /{s}. Try /help", .{cmd.name}); + } + // Plain text (no command) — ignore silently +} diff --git a/tools/mcp/trinity_mcp/bot/command_parser.zig b/tools/mcp/trinity_mcp/bot/command_parser.zig new file mode 100644 index 0000000000..d68feebc5c --- /dev/null +++ b/tools/mcp/trinity_mcp/bot/command_parser.zig @@ -0,0 +1,69 @@ +// command_parser.zig — Parse /command args from Telegram message text +const std = @import("std"); + +pub const Command = struct { + name: []const u8, // e.g. "ask", "status", "help" + args: []const u8, // everything after the command name +}; + +/// Parse a Telegram message into a Command. +/// "/ask what is this project" → {name:"ask", args:"what is this project"} +/// "hello" → {name:"", args:"hello"} (not a command) +pub fn parse(text: []const u8) Command { + if (text.len == 0) return .{ .name = "", .args = "" }; + if (text[0] != '/') return .{ .name = "", .args = text }; + + // Skip the / + const after_slash = text[1..]; + + // Find end of command name (space or @botname or end) + var name_end: usize = 0; + while (name_end < after_slash.len) : (name_end += 1) { + const c = after_slash[name_end]; + if (c == ' ' or c == '@') break; + } + + const name = after_slash[0..name_end]; + + // Skip spaces after command + var args_start = name_end; + // Skip @botname if present + if (args_start < after_slash.len and after_slash[args_start] == '@') { + while (args_start < after_slash.len and after_slash[args_start] != ' ') : (args_start += 1) {} + } + while (args_start < after_slash.len and after_slash[args_start] == ' ') : (args_start += 1) {} + + const args = if (args_start < after_slash.len) after_slash[args_start..] else ""; + + return .{ .name = name, .args = args }; +} + +test "parse /ask with args" { + const cmd = parse("/ask what is this project"); + try std.testing.expectEqualStrings("ask", cmd.name); + try std.testing.expectEqualStrings("what is this project", cmd.args); +} + +test "parse /help no args" { + const cmd = parse("/help"); + try std.testing.expectEqualStrings("help", cmd.name); + try std.testing.expectEqualStrings("", cmd.args); +} + +test "parse /ask@bot_name with args" { + const cmd = parse("/ask@trinity_bot hello world"); + try std.testing.expectEqualStrings("ask", cmd.name); + try std.testing.expectEqualStrings("hello world", cmd.args); +} + +test "parse plain text" { + const cmd = parse("hello world"); + try std.testing.expectEqualStrings("", cmd.name); + try std.testing.expectEqualStrings("hello world", cmd.args); +} + +test "parse empty" { + const cmd = parse(""); + try std.testing.expectEqualStrings("", cmd.name); + try std.testing.expectEqualStrings("", cmd.args); +} diff --git a/tools/mcp/trinity_mcp/bot/handlers.zig b/tools/mcp/trinity_mcp/bot/handlers.zig new file mode 100644 index 0000000000..e1b687daf4 --- /dev/null +++ b/tools/mcp/trinity_mcp/bot/handlers.zig @@ -0,0 +1,145 @@ +// handlers.zig — Command handlers for tri-bot +// Each handler: receives args + config, runs claude CLI, sends result to Telegram +const std = @import("std"); +const telegram_api = @import("telegram_api.zig"); + +const BotConfig = telegram_api.BotConfig; + +/// /help — Send list of available commands +pub fn handleHelp(allocator: std.mem.Allocator, config: BotConfig) void { + const help_text = + "\xf0\x9f\xa4\x96 TRI BOT \xe2\x80\x94 Claude Code Remote Control\n" ++ + "\n" ++ + "/ask \xe2\x80\x94 Ask Claude anything\n" ++ + "/continue \xe2\x80\x94 Continue last session\n" ++ + "/status \xe2\x80\x94 Project status\n" ++ + "/stop \xe2\x80\x94 Stop running Claude process\n" ++ + "/help \xe2\x80\x94 This message\n" ++ + "\n" ++ + "Phase 2: /resume, /model, /board, /pr, /worktree"; + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, help_text); +} + +/// /ask — Run claude -p "" and send result +pub fn handleAsk(allocator: std.mem.Allocator, config: BotConfig, args: []const u8) void { + if (args.len == 0) { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Usage: /ask "); + return; + } + + // Notify user + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa7\xa0 TRI thinking..."); + + // Build turns string + var turns_buf: [16]u8 = undefined; + const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{config.max_turns}) catch "10"; + + // Spawn claude -p "" --output-format text --max-turns N + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ "claude", "-p", args, "--output-format", "text", "--max-turns", turns_str }, + .cwd = config.project_root, + .max_output_bytes = 512 * 1024, + }) catch |err| { + var err_buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Claude error: {s}", .{@errorName(err)}); + return; + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + if (result.stdout.len == 0) { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Claude returned empty response"); + return; + } + + // Telegram message limit: 4096 chars. Split if needed. + sendLongMessage(allocator, config, result.stdout); +} + +/// /continue — Run claude -p "" --continue +pub fn handleContinue(allocator: std.mem.Allocator, config: BotConfig, args: []const u8) void { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x94\x84 TRI continuing..."); + + var turns_buf: [16]u8 = undefined; + const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{config.max_turns}) catch "10"; + + const argv: []const []const u8 = if (args.len > 0) + &.{ "claude", "-p", args, "--continue", "--output-format", "text", "--max-turns", turns_str } + else + &.{ "claude", "--continue", "--output-format", "text", "--max-turns", turns_str }; + + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = argv, + .cwd = config.project_root, + .max_output_bytes = 512 * 1024, + }) catch |err| { + var err_buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Claude error: {s}", .{@errorName(err)}); + return; + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + if (result.stdout.len == 0) { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 Claude returned empty response"); + return; + } + + sendLongMessage(allocator, config, result.stdout); +} + +/// /status — Run claude -p "Summarize project status in 5 lines" +pub fn handleStatus(allocator: std.mem.Allocator, config: BotConfig) void { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\x93\x8a TRI checking status..."); + + const result = std.process.Child.run(.{ + .allocator = allocator, + .argv = &.{ + "claude", "-p", + "Summarize the project status in 5 short lines: open issues count, last commit, test status, current branch, any blockers. Be concise.", "--output-format", + "text", "--max-turns", + "3", + }, + .cwd = config.project_root, + .max_output_bytes = 64 * 1024, + }) catch |err| { + var err_buf: [256]u8 = undefined; + telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &err_buf, "\xe2\x9d\x8c Status error: {s}", .{@errorName(err)}); + return; + }; + defer allocator.free(result.stdout); + defer allocator.free(result.stderr); + + if (result.stdout.len == 0) { + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9a\xa0 No status available"); + return; + } + + sendLongMessage(allocator, config, result.stdout); +} + +/// Send a potentially long message, splitting at 4096 char boundary +fn sendLongMessage(allocator: std.mem.Allocator, config: BotConfig, text: []const u8) void { + const max_len: usize = 4000; // Leave some margin below 4096 + var pos: usize = 0; + + while (pos < text.len) { + const end = @min(pos + max_len, text.len); + // Try to split at a newline + var split = end; + if (end < text.len) { + var j = end; + while (j > pos + max_len / 2) : (j -= 1) { + if (text[j] == '\n') { + split = j + 1; + break; + } + } + } + const chunk = text[pos..split]; + telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, chunk); + pos = split; + } +} diff --git a/tools/mcp/trinity_mcp/bot/json_utils.zig b/tools/mcp/trinity_mcp/bot/json_utils.zig new file mode 100644 index 0000000000..64461a4367 --- /dev/null +++ b/tools/mcp/trinity_mcp/bot/json_utils.zig @@ -0,0 +1,97 @@ +// json_utils.zig — Simple JSON extraction without full parser +// Pattern from ralph_hook.zig extractJsonString +const std = @import("std"); + +/// Extract a JSON string value by key: "key":"value" +pub fn extractString(json: []const u8, key: []const u8) ?[]const u8 { + var needle_buf: [128]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"{s}\":\"", .{key}) catch return null; + + const idx = std.mem.indexOf(u8, json, needle) orelse return null; + const start = idx + needle.len; + if (start >= json.len) return null; + + var end = start; + while (end < json.len) : (end += 1) { + if (json[end] == '"' and (end == start or json[end - 1] != '\\')) break; + } + if (end == start) return null; + return json[start..end]; +} + +/// Extract a JSON integer value by key: "key":123 +pub fn extractInt(json: []const u8, key: []const u8) ?i64 { + var needle_buf: [128]u8 = undefined; + const needle = std.fmt.bufPrint(&needle_buf, "\"{s}\":", .{key}) catch return null; + + const idx = std.mem.indexOf(u8, json, needle) orelse return null; + const start = idx + needle.len; + if (start >= json.len) return null; + + // Skip whitespace + var s = start; + while (s < json.len and (json[s] == ' ' or json[s] == '\t')) : (s += 1) {} + if (s >= json.len) return null; + + // Handle negative + const negative = json[s] == '-'; + if (negative) s += 1; + + var end = s; + while (end < json.len and json[end] >= '0' and json[end] <= '9') : (end += 1) {} + if (end == s) return null; + + const val = std.fmt.parseInt(i64, json[s..end], 10) catch return null; + return if (negative) -val else val; +} + +/// Find all "update_id":N values and their corresponding "text":"..." in getUpdates response. +/// Calls callback for each update found. +pub fn iterateUpdates(json: []const u8, callback: *const fn (update_id: i64, chat_id: i64, text: []const u8) void) void { + // Find each {"update_id": block + var pos: usize = 0; + while (pos < json.len) { + const needle = "\"update_id\":"; + const idx = std.mem.indexOfPos(u8, json, pos, needle) orelse break; + + // Find the enclosing object boundaries (rough: next "update_id" or end) + const next_idx = std.mem.indexOfPos(u8, json, idx + needle.len + 1, needle) orelse json.len; + const block = json[idx..next_idx]; + + const uid = extractInt(block, "update_id") orelse { + pos = idx + needle.len; + continue; + }; + + // Extract chat_id from nested message.chat.id — look for "chat":{"id":N + const chat_id = blk: { + const chat_needle = "\"chat\":{\"id\":"; + const ci = std.mem.indexOf(u8, block, chat_needle) orelse break :blk @as(i64, 0); + const cs = ci + chat_needle.len; + var ce = cs; + while (ce < block.len and ((block[ce] >= '0' and block[ce] <= '9') or block[ce] == '-')) : (ce += 1) {} + break :blk std.fmt.parseInt(i64, block[cs..ce], 10) catch 0; + }; + + const text = extractString(block, "text") orelse ""; + + callback(uid, chat_id, text); + pos = next_idx; + } +} + +test "extractString basic" { + const json = "{\"name\":\"hello\",\"value\":\"world\"}"; + try std.testing.expectEqualStrings("hello", extractString(json, "name").?); + try std.testing.expectEqualStrings("world", extractString(json, "value").?); +} + +test "extractInt basic" { + const json = "{\"update_id\":12345,\"count\":-7}"; + try std.testing.expectEqual(@as(i64, 12345), extractInt(json, "update_id").?); + try std.testing.expectEqual(@as(i64, -7), extractInt(json, "count").?); +} + +test "extractString missing" { + try std.testing.expect(extractString("{}", "missing") == null); +} diff --git a/tools/mcp/trinity_mcp/bot/main.zig b/tools/mcp/trinity_mcp/bot/main.zig new file mode 100644 index 0000000000..f9115ec45d --- /dev/null +++ b/tools/mcp/trinity_mcp/bot/main.zig @@ -0,0 +1,44 @@ +// main.zig — TRI BOT entry point +// Telegram bot as Claude Code CLI remote control +const std = @import("std"); +const bot_loop = @import("bot_loop.zig"); +const telegram_api = @import("telegram_api.zig"); + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Read configuration from environment + const bot_token = std.posix.getenv("TELEGRAM_BOT_TOKEN") orelse { + std.debug.print("[tri-bot] ERROR: TELEGRAM_BOT_TOKEN not set\n", .{}); + return error.MissingConfig; + }; + const chat_id = std.posix.getenv("TELEGRAM_CHAT_ID") orelse { + std.debug.print("[tri-bot] ERROR: TELEGRAM_CHAT_ID not set\n", .{}); + return error.MissingConfig; + }; + const project_root = std.posix.getenv("PROJECT_ROOT") orelse + std.posix.getenv("TRINITY_PROJECT_ROOT") orelse "."; + + const max_turns_str = std.posix.getenv("MAX_TURNS") orelse "10"; + const max_turns = std.fmt.parseInt(u32, max_turns_str, 10) catch 10; + + const config = telegram_api.BotConfig{ + .bot_token = bot_token, + .chat_id = chat_id, + .project_root = project_root, + .max_turns = max_turns, + }; + + std.debug.print( + \\[tri-bot] TRI BOT v1.0.0 + \\[tri-bot] Chat ID: {s} + \\[tri-bot] Project: {s} + \\[tri-bot] Max turns: {d} + \\ + , .{ chat_id, project_root, max_turns }); + + // Run the bot loop (never returns) + bot_loop.run(allocator, config); +} diff --git a/tools/mcp/trinity_mcp/bot/telegram_api.zig b/tools/mcp/trinity_mcp/bot/telegram_api.zig new file mode 100644 index 0000000000..0e6646e3c0 --- /dev/null +++ b/tools/mcp/trinity_mcp/bot/telegram_api.zig @@ -0,0 +1,137 @@ +// telegram_api.zig — Telegram Bot API: getUpdates (long poll) + sendMessage + sendMessageDraft +// Pattern from agent/telegram.zig (send) and agent/github_poller.zig (HTTP GET) +const std = @import("std"); + +pub const BotConfig = struct { + bot_token: []const u8, + chat_id: []const u8, + project_root: []const u8, + max_turns: u32, +}; + +/// Poll Telegram getUpdates with long polling. +/// Returns raw JSON response body or null on error. +pub fn getUpdates(allocator: std.mem.Allocator, bot_token: []const u8, offset: i64) ?[]const u8 { + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "https://api.telegram.org/bot{s}/getUpdates?timeout=30&offset={d}", .{ bot_token, offset }) catch return null; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + var aw: std.Io.Writer.Allocating = .init(allocator); + defer aw.deinit(); + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = .GET, + .response_writer = &aw.writer, + }) catch |err| { + log("getUpdates error: {s}", .{@errorName(err)}); + return null; + }; + + if (result.status != .ok) { + log("getUpdates status: {d}", .{@intFromEnum(result.status)}); + return null; + } + + const body = aw.written(); + return allocator.dupe(u8, body) catch null; +} + +/// Send a message via Telegram Bot API sendMessage. +pub fn sendMessage(allocator: std.mem.Allocator, bot_token: []const u8, chat_id: []const u8, text: []const u8) void { + sendToEndpoint(allocator, bot_token, chat_id, "sendMessage", text); +} + +/// Send a streaming draft via Telegram Bot API 9.5 sendMessageDraft. +pub fn sendDraft(allocator: std.mem.Allocator, bot_token: []const u8, chat_id: []const u8, text: []const u8) void { + sendToEndpoint(allocator, bot_token, chat_id, "sendMessageDraft", text); +} + +/// Send a formatted message. +pub fn sendFmt(allocator: std.mem.Allocator, bot_token: []const u8, chat_id: []const u8, buf: []u8, comptime fmt: []const u8, args: anytype) void { + const msg = std.fmt.bufPrint(buf, fmt, args) catch return; + sendMessage(allocator, bot_token, chat_id, msg); +} + +fn sendToEndpoint(allocator: std.mem.Allocator, bot_token: []const u8, chat_id: []const u8, endpoint: []const u8, text: []const u8) void { + var url_buf: [512]u8 = undefined; + const url = std.fmt.bufPrint(&url_buf, "https://api.telegram.org/bot{s}/{s}", .{ bot_token, endpoint }) catch return; + + // Build JSON body with manual escaping + var body_buf: [8192]u8 = undefined; + var i: usize = 0; + + const prefix = "{\"chat_id\":\""; + @memcpy(body_buf[i..][0..prefix.len], prefix); + i += prefix.len; + @memcpy(body_buf[i..][0..chat_id.len], chat_id); + i += chat_id.len; + + const mid = "\",\"text\":\""; + @memcpy(body_buf[i..][0..mid.len], mid); + i += mid.len; + + // JSON-escape the text + for (text) |c| { + if (i + 2 >= body_buf.len - 10) break; + switch (c) { + '"' => { + body_buf[i] = '\\'; + body_buf[i + 1] = '"'; + i += 2; + }, + '\\' => { + body_buf[i] = '\\'; + body_buf[i + 1] = '\\'; + i += 2; + }, + '\n' => { + body_buf[i] = '\\'; + body_buf[i + 1] = 'n'; + i += 2; + }, + '\r' => { + body_buf[i] = '\\'; + body_buf[i + 1] = 'r'; + i += 2; + }, + else => { + body_buf[i] = c; + i += 1; + }, + } + } + + const suffix = "\"}"; + if (i + suffix.len <= body_buf.len) { + @memcpy(body_buf[i..][0..suffix.len], suffix); + i += suffix.len; + } + + const body = body_buf[0..i]; + + var client = std.http.Client{ .allocator = allocator }; + defer client.deinit(); + + const result = client.fetch(.{ + .location = .{ .url = url }, + .method = .POST, + .payload = body, + .extra_headers = &.{ + .{ .name = "Content-Type", .value = "application/json" }, + }, + }) catch |err| { + log("send error: {s}", .{@errorName(err)}); + return; + }; + + if (result.status != .ok) { + log("API {s} status: {d}", .{ endpoint, @intFromEnum(result.status) }); + } +} + +fn log(comptime fmt: []const u8, args: anytype) void { + std.debug.print("[tri-bot] " ++ fmt ++ "\n", args); +}