diff --git a/.claude-plugin/hooks/hooks.json b/.claude-plugin/hooks/hooks.json
index fb8a888e5d..baf9692115 100644
--- a/.claude-plugin/hooks/hooks.json
+++ b/.claude-plugin/hooks/hooks.json
@@ -12,6 +12,11 @@
"type": "command",
"command": "ROOT=$(git rev-parse --show-toplevel 2>/dev/null || echo '.'); echo \"{\\\"event\\\":\\\"agent_stop\\\",\\\"branch\\\":\\\"$(git branch --show-current 2>/dev/null || echo unknown)\\\",\\\"ts\\\":$(date +%s)}\" >> \"$ROOT/.ralph/god_mode_log.jsonl\" 2>/dev/null || true",
"timeout": 5
+ },
+ {
+ "type": "command",
+ "command": "test -x \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" && echo '{\"hook_event_name\":\"Stop\"}' | \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" 2>/dev/null || true",
+ "timeout": 10
}
]
}
@@ -88,6 +93,27 @@
"timeout": 5
}
]
+ },
+ {
+ "matcher": "Bash|Edit|Write|Skill",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "test -x \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" && echo $CLAUDE_TOOL_INPUT | \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" 2>/dev/null || true",
+ "timeout": 10
+ }
+ ]
+ }
+ ],
+ "PostToolUseFailure": [
+ {
+ "hooks": [
+ {
+ "type": "command",
+ "command": "test -x \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" && echo '{\"hook_event_name\":\"PostToolUseFailure\"}' | \"${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}/zig-out/bin/ralph-hook\" 2>/dev/null || true",
+ "timeout": 10
+ }
+ ]
}
]
}
diff --git a/.mcp.json b/.mcp.json
index 73818b06d4..2d054ca60d 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -13,6 +13,13 @@
"railway-mcp-server": {
"command": "npx",
"args": ["-y", "@railway/mcp-server"]
+ },
+ "telegram": {
+ "command": "npx",
+ "args": ["-y", "@iqai/mcp-telegram"],
+ "env": {
+ "TELEGRAM_BOT_TOKEN": "${TELEGRAM_BOT_TOKEN}"
+ }
}
}
}
diff --git a/build.zig b/build.zig
index 52cab9f273..baf71479ba 100644
--- a/build.zig
+++ b/build.zig
@@ -1326,6 +1326,17 @@ pub fn build(b: *std.Build) void {
const agent_step = b.step("agent", "Run Ralph autonomous agent daemon");
agent_step.dependOn(&run_agent.step);
+ // Ralph Hook — Tiny binary for Claude Code hooks → Telegram
+ const ralph_hook = b.addExecutable(.{
+ .name = "ralph-hook",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("tools/mcp/trinity_mcp/agent/ralph_hook.zig"),
+ .target = target,
+ .optimize = optimize,
+ }),
+ });
+ b.installArtifact(ralph_hook);
+
// ═══════════════════════════════════════════════════════════════════════════
// PHI LOOP — 999 Links of Cosmic Consciousness Gene
const phi_loop = b.addExecutable(.{
diff --git a/tools/mcp/trinity_mcp/agent/agent_loop.zig b/tools/mcp/trinity_mcp/agent/agent_loop.zig
index 9956a3b84f..b22e1cccbd 100644
--- a/tools/mcp/trinity_mcp/agent/agent_loop.zig
+++ b/tools/mcp/trinity_mcp/agent/agent_loop.zig
@@ -1,4 +1,6 @@
// agent_loop.zig — Sleep-wake cycle for Ralph autonomous agent
+// Hooks handle per-tool Telegram reporting. This loop only sends WAKE/SLEEP.
+// Uses --continue for native session resume (replaces HANDOVER.md).
const std = @import("std");
const identity_mod = @import("identity.zig");
const handover = @import("handover.zig");
@@ -6,6 +8,7 @@ const github_poller = @import("github_poller.zig");
const context_builder = @import("context_builder.zig");
const claude_runner = @import("claude_runner.zig");
const state_mod = @import("state.zig");
+const telegram = @import("telegram.zig");
pub const Config = struct {
project_root: []const u8,
@@ -15,7 +18,8 @@ pub const Config = struct {
sleep_interval_s: u64 = 1800, // 30 minutes
max_turns: u32 = 50,
max_wakes: u32 = 0, // 0 = infinite
- single_shot: bool = false, // true = run once and exit
+ single_shot: bool = false,
+ tg_config: telegram.TelegramConfig = .{ .bot_token = "", .chat_id = "", .enabled = false },
};
fn log(comptime fmt: []const u8, args: anytype) void {
@@ -32,14 +36,20 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
log(" repo: {s}/{s}", .{ config.owner, config.repo });
log(" sleep interval: {d}s", .{config.sleep_interval_s});
log(" max turns: {d}", .{config.max_turns});
+ log(" telegram: {s}", .{if (config.tg_config.enabled) "enabled" else "disabled"});
while (true) {
// === WAKE ===
const wake_count = state.incrementWakeCount() catch 0;
log("=== WAKE #{d} ===", .{wake_count});
+ // Telegram: announce wake
+ var tg_buf: [512]u8 = undefined;
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | WAKE #{d}", .{wake_count});
+
if (config.max_wakes > 0 and wake_count > config.max_wakes) {
log("Max wakes ({d}) reached. Exiting.", .{config.max_wakes});
+ telegram.send(config.tg_config, "ralph | Max wakes reached. Stopping.");
break;
}
@@ -47,7 +57,7 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
var id = identity_mod.load(allocator, config.project_root);
defer id.deinit();
- // Read previous handover
+ // Read previous handover (used for first wake context only)
const handover_content = handover.read(allocator, config.project_root);
defer if (handover_content) |h| allocator.free(h);
@@ -63,11 +73,15 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
if (issues_json == null) {
log("No pending issues or GitHub API unavailable. Sleeping.", .{});
+ telegram.send(config.tg_config, "ralph | No issues found. Sleeping.");
if (config.single_shot) break;
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
continue;
}
+ // Telegram: issues found
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Issues found, building context...", .{});
+
// Read current state
const current_issue = state.read("current_issue");
defer if (current_issue) |v| allocator.free(v);
@@ -87,14 +101,25 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
log("Context built ({d} bytes). Spawning Claude CLI...", .{prompt.len});
+ // Use --continue for session resume after first wake
+ const use_continue = wake_count > 1;
+
+ // Telegram: spawning Claude
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Spawning Claude ({d}b context, {s})...", .{
+ prompt.len,
+ if (use_continue) "--continue" else "new session",
+ });
+
// === WORK ===
var result = claude_runner.spawn(
allocator,
prompt,
config.project_root,
config.max_turns,
+ use_continue,
) catch |err| {
log("Claude spawn error: {s}", .{@errorName(err)});
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Claude spawn FAILED: {s}", .{@errorName(err)});
if (config.single_shot) break;
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
continue;
@@ -103,22 +128,17 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
log("Claude exited with code {d} ({d} bytes output)", .{ result.exit_code, result.stdout.len });
+ // Telegram: Claude finished
+ if (result.exit_code == 0) {
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Claude done ({d}b output)", .{result.stdout.len});
+ } else {
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Claude exit={d} ({d}b output)", .{ result.exit_code, result.stdout.len });
+ }
+
// Save session log
claude_runner.saveLog(allocator, config.project_root, result.stdout);
// === SLEEP ===
- // Check if handover was written by the session
- const new_handover = handover.read(allocator, config.project_root);
- if (new_handover) |nh| {
- allocator.free(nh);
- } else {
- // Emergency handover — session didn't write one
- log("WARNING: No handover written. Creating emergency handover.", .{});
- handover.writeEmergency(allocator, config.project_root, wake_count, current_issue) catch {
- log("Failed to write emergency handover!", .{});
- };
- }
-
// Update state
var count_buf: [16]u8 = undefined;
const count_str = std.fmt.bufPrint(&count_buf, "{d}", .{wake_count}) catch "0";
@@ -126,10 +146,12 @@ pub fn run(allocator: std.mem.Allocator, config: Config) !void {
if (config.single_shot) {
log("Single-shot mode. Exiting.", .{});
+ telegram.send(config.tg_config, "ralph | Single-shot done. Exiting.");
break;
}
log("Sleeping for {d}s...", .{config.sleep_interval_s});
+ telegram.sendFmt(config.tg_config, &tg_buf, "ralph | Sleeping {d}s...", .{config.sleep_interval_s});
std.Thread.sleep(config.sleep_interval_s * std.time.ns_per_s);
}
diff --git a/tools/mcp/trinity_mcp/agent/claude_runner.zig b/tools/mcp/trinity_mcp/agent/claude_runner.zig
index f8fb712984..c83fb614af 100644
--- a/tools/mcp/trinity_mcp/agent/claude_runner.zig
+++ b/tools/mcp/trinity_mcp/agent/claude_runner.zig
@@ -1,4 +1,5 @@
// claude_runner.zig — Spawn Claude Code CLI as child process
+// Supports --continue for native session resume (replaces HANDOVER.md)
const std = @import("std");
pub const RunResult = struct {
@@ -11,37 +12,65 @@ pub const RunResult = struct {
}
};
-/// Spawn `claude` CLI with the given prompt as a positional argument.
-/// Returns captured stdout and exit code.
+const allowed_tools = "Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill,mcp__telegram__SEND_MESSAGE";
+
+/// Spawn `claude` CLI. If use_continue=true, uses --continue for session resume.
+/// Otherwise, passes prompt via -p flag for a fresh session.
+/// Hooks handle per-tool Telegram reporting — no stdout parsing needed.
pub fn spawn(
allocator: std.mem.Allocator,
prompt: []const u8,
project_root: []const u8,
max_turns: u32,
+ use_continue: bool,
) !RunResult {
var turns_buf: [8]u8 = undefined;
const turns_str = std.fmt.bufPrint(&turns_buf, "{d}", .{max_turns}) catch "50";
- // claude --print --output-format text --max-turns N --allowedTools ... "prompt"
- const result = std.process.Child.run(.{
- .allocator = allocator,
- .argv = &.{
- "claude",
- "--print",
- "--output-format",
- "text",
- "--max-turns",
- turns_str,
- "--allowedTools",
- "Bash,Read,Write,Edit,Glob,Grep,TodoWrite,WebFetch,WebSearch,Skill",
- "-p",
- prompt,
- },
- .cwd = project_root,
- .max_output_bytes = 1024 * 1024, // 1MB
- }) catch |err| {
- const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)});
- return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
+ // Build argv based on resume mode
+ const result = if (use_continue) blk: {
+ break :blk std.process.Child.run(.{
+ .allocator = allocator,
+ .argv = &.{
+ "claude",
+ "--continue",
+ "--print",
+ "--output-format",
+ "text",
+ "--max-turns",
+ turns_str,
+ "--allowedTools",
+ allowed_tools,
+ "-p",
+ prompt,
+ },
+ .cwd = project_root,
+ .max_output_bytes = 1024 * 1024,
+ }) catch |err| {
+ const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude --continue: {s}", .{@errorName(err)});
+ return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
+ };
+ } else blk: {
+ break :blk std.process.Child.run(.{
+ .allocator = allocator,
+ .argv = &.{
+ "claude",
+ "--print",
+ "--output-format",
+ "text",
+ "--max-turns",
+ turns_str,
+ "--allowedTools",
+ allowed_tools,
+ "-p",
+ prompt,
+ },
+ .cwd = project_root,
+ .max_output_bytes = 1024 * 1024,
+ }) catch |err| {
+ const msg = try std.fmt.allocPrint(allocator, "Failed to spawn claude: {s}", .{@errorName(err)});
+ return RunResult{ .stdout = msg, .exit_code = 1, .allocator = allocator };
+ };
};
// Free stderr, keep stdout
diff --git a/tools/mcp/trinity_mcp/agent/main.zig b/tools/mcp/trinity_mcp/agent/main.zig
index 4ccec6db78..69abb1ddb8 100644
--- a/tools/mcp/trinity_mcp/agent/main.zig
+++ b/tools/mcp/trinity_mcp/agent/main.zig
@@ -12,9 +12,12 @@
// RALPH_MAX_TURNS — Max Claude CLI turns per session (default: 50)
// RALPH_MAX_WAKES — Max wake cycles, 0=infinite (default: 0)
// PROJECT_ROOT — Project root path (auto-detected if unset)
+// TELEGRAM_BOT_TOKEN — Telegram bot token (optional, enables TG reporting)
+// TELEGRAM_CHAT_ID — Telegram chat ID (optional, enables TG reporting)
//
const std = @import("std");
const agent_loop = @import("agent_loop.zig");
+const telegram = @import("telegram.zig");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
@@ -38,6 +41,11 @@ pub fn main() !void {
const max_turns = std.fmt.parseInt(u32, turns_s, 10) catch 50;
const max_wakes = std.fmt.parseInt(u32, wakes_s, 10) catch 0;
+ // Telegram reporting (optional)
+ const tg_token: []const u8 = std.posix.getenv("TELEGRAM_BOT_TOKEN") orelse "";
+ const tg_chat_id: []const u8 = std.posix.getenv("TELEGRAM_CHAT_ID") orelse "";
+ const tg_enabled = tg_token.len > 0 and tg_chat_id.len > 0;
+
// Detect project root
const project_root = blk: {
if (std.posix.getenv("PROJECT_ROOT")) |root| break :blk @as([]const u8, root);
@@ -69,12 +77,18 @@ pub fn main() !void {
}
std.debug.print(
- \\[ralph-agent] Ralph Autonomous Agent v1.0.0
- \\[ralph-agent] φ² + 1/φ² = 3
+ \\[ralph-agent] Ralph Autonomous Agent v2.0.0
+ \\[ralph-agent] Hooks + Telegram + Session Resume
\\[ralph-agent] ---
\\
, .{});
+ if (tg_enabled) {
+ std.debug.print("[ralph-agent] Telegram: enabled (chat_id={s})\n", .{tg_chat_id});
+ } else {
+ std.debug.print("[ralph-agent] Telegram: disabled (set TELEGRAM_BOT_TOKEN + TELEGRAM_CHAT_ID)\n", .{});
+ }
+
try agent_loop.run(allocator, .{
.project_root = project_root,
.gh_token = gh_token,
@@ -84,5 +98,10 @@ pub fn main() !void {
.max_turns = max_turns,
.max_wakes = max_wakes,
.single_shot = single_shot,
+ .tg_config = .{
+ .bot_token = tg_token,
+ .chat_id = tg_chat_id,
+ .enabled = tg_enabled,
+ },
});
}
diff --git a/tools/mcp/trinity_mcp/agent/ralph_hook.zig b/tools/mcp/trinity_mcp/agent/ralph_hook.zig
new file mode 100644
index 0000000000..6571a8993c
--- /dev/null
+++ b/tools/mcp/trinity_mcp/agent/ralph_hook.zig
@@ -0,0 +1,92 @@
+// ralph_hook.zig — Tiny binary for Claude Code hooks → Telegram
+//
+// Called by Claude Code hooks (type: "command"):
+// Reads JSON from stdin: {hook_event_name, tool_name, tool_input, tool_output}
+// Formats and sends to Telegram via sendMessage
+//
+// Usage in .claude-plugin/hooks/hooks.json:
+// "command": "$CLAUDE_PROJECT_DIR/zig-out/bin/ralph-hook"
+//
+// Env vars: TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID
+const std = @import("std");
+const telegram = @import("telegram.zig");
+
+pub fn main() !void {
+ // Read Telegram config from env
+ const bot_token: []const u8 = std.posix.getenv("TELEGRAM_BOT_TOKEN") orelse return;
+ const chat_id: []const u8 = std.posix.getenv("TELEGRAM_CHAT_ID") orelse return;
+ const config = telegram.TelegramConfig{
+ .bot_token = bot_token,
+ .chat_id = chat_id,
+ .enabled = true,
+ };
+
+ // Read hook JSON from stdin via posix.read
+ var input_buf: [65536]u8 = undefined;
+ var total: usize = 0;
+ while (total < input_buf.len) {
+ const n = std.posix.read(0, input_buf[total..]) catch break;
+ if (n == 0) break;
+ total += n;
+ }
+ if (total == 0) return;
+ const input = input_buf[0..total];
+
+ // Extract fields via simple string search
+ const event = extractJsonString(input, "hook_event_name") orelse "unknown";
+ const tool = extractJsonString(input, "tool_name") orelse "";
+
+ // Format message based on event type
+ var buf: [512]u8 = undefined;
+ const msg = if (std.mem.eql(u8, event, "PostToolUse"))
+ std.fmt.bufPrint(&buf, "ralph | {s} done", .{truncate(tool, 40)}) catch return
+ else if (std.mem.eql(u8, event, "PostToolUseFailure"))
+ std.fmt.bufPrint(&buf, "ralph | {s} FAILED", .{truncate(tool, 40)}) catch return
+ else if (std.mem.eql(u8, event, "PreToolUse"))
+ std.fmt.bufPrint(&buf, "ralph | {s}...", .{truncate(tool, 40)}) catch return
+ else if (std.mem.eql(u8, event, "Stop"))
+ std.fmt.bufPrint(&buf, "ralph | Session finished", .{}) catch return
+ else if (std.mem.eql(u8, event, "SessionStart"))
+ std.fmt.bufPrint(&buf, "ralph | Session started", .{}) catch return
+ else
+ return; // Unknown event, skip
+
+ telegram.send(config, msg);
+}
+
+/// Extract a JSON string value by key using simple pattern matching.
+/// Looks for "key":"value" in the input.
+fn extractJsonString(json: []const u8, key: []const u8) ?[]const u8 {
+ // Build needle: "key":"
+ 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;
+
+ // Find closing quote (skip escaped quotes)
+ 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];
+}
+
+fn truncate(s: []const u8, max: usize) []const u8 {
+ return if (s.len <= max) s else s[0..max];
+}
+
+test "extractJsonString basic" {
+ const json = "{\"hook_event_name\":\"PostToolUse\",\"tool_name\":\"Bash\"}";
+ const event = extractJsonString(json, "hook_event_name") orelse return error.NotFound;
+ try std.testing.expectEqualStrings("PostToolUse", event);
+ const tool = extractJsonString(json, "tool_name") orelse return error.NotFound;
+ try std.testing.expectEqualStrings("Bash", tool);
+}
+
+test "extractJsonString missing key" {
+ const json = "{\"hook_event_name\":\"Stop\"}";
+ try std.testing.expect(extractJsonString(json, "tool_name") == null);
+}
diff --git a/tools/mcp/trinity_mcp/agent/telegram.zig b/tools/mcp/trinity_mcp/agent/telegram.zig
new file mode 100644
index 0000000000..0c85db42a6
--- /dev/null
+++ b/tools/mcp/trinity_mcp/agent/telegram.zig
@@ -0,0 +1,120 @@
+// telegram.zig — Fire-and-forget Telegram sender for ralph-agent
+// Uses sendMessage + sendMessageDraft (Bot API 9.5 streaming)
+// Pattern from oracle_watchdog.zig:515-598
+const std = @import("std");
+
+pub const TelegramConfig = struct {
+ bot_token: []const u8,
+ chat_id: []const u8,
+ enabled: bool,
+};
+
+/// Send a final message via Telegram Bot API sendMessage.
+/// Fire-and-forget: errors logged to stderr, never propagated.
+pub fn send(config: TelegramConfig, text: []const u8) void {
+ if (!config.enabled) return;
+ sendToEndpoint(config, "sendMessage", text);
+}
+
+/// Send a streaming draft via Telegram Bot API 9.5 sendMessageDraft.
+/// Repeated calls with growing text = live streaming in chat.
+pub fn sendDraft(config: TelegramConfig, text: []const u8) void {
+ if (!config.enabled) return;
+ sendToEndpoint(config, "sendMessageDraft", text);
+}
+
+/// Format a message and send it.
+pub fn sendFmt(config: TelegramConfig, buf: []u8, comptime fmt: []const u8, args: anytype) void {
+ if (!config.enabled) return;
+ const msg = std.fmt.bufPrint(buf, fmt, args) catch return;
+ send(config, msg);
+}
+
+fn sendToEndpoint(config: TelegramConfig, endpoint: []const u8, text: []const u8) void {
+ // Build URL: https://api.telegram.org/bot{token}/{endpoint}
+ var url_buf: [512]u8 = undefined;
+ const url = std.fmt.bufPrint(&url_buf, "https://api.telegram.org/bot{s}/{s}", .{ config.bot_token, endpoint }) catch return;
+
+ // Build JSON body with manual escaping
+ var body_buf: [4096]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..config.chat_id.len], config.chat_id);
+ i += config.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 - 30) break; // reserve space for suffix
+ 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 = "\",\"parse_mode\":\"HTML\"}";
+ if (i + suffix.len <= body_buf.len) {
+ @memcpy(body_buf[i..][0..suffix.len], suffix);
+ i += suffix.len;
+ }
+
+ const body = body_buf[0..i];
+
+ // Fire-and-forget HTTP POST (internal GPA per-call)
+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
+ defer _ = gpa.deinit();
+ const allocator = gpa.allocator();
+
+ 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| {
+ std.debug.print("[telegram] send error: {s}\n", .{@errorName(err)});
+ return;
+ };
+
+ if (result.status != .ok) {
+ std.debug.print("[telegram] API returned status {d}\n", .{@intFromEnum(result.status)});
+ }
+}
+
+test "TelegramConfig disabled does not crash" {
+ const config = TelegramConfig{ .bot_token = "", .chat_id = "", .enabled = false };
+ send(config, "test");
+ sendDraft(config, "test");
+}