Skip to content

Commit b87f6aa

Browse files
gHashTagAntigravity Agentclaude
authored
feat(bot): TRI BOT Phase 1 — Telegram bot as Claude Code CLI remote control (#54)
* feat(bot): TRI BOT Phase 1 — Telegram bot as Claude Code CLI remote control 4.3MB native Zig binary. Long-polling getUpdates, 4 commands (/ask, /continue, /status, /help), auth check on chat_id, message splitting at 4000 chars. 6 modules: main, bot_loop, telegram_api, command_parser, handlers, json_utils. VIBEE spec: specs/tri/tri_bot.vibee. Build target: `zig build tri-bot`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * style(bot): zig fmt handlers.zig 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Antigravity Agent <antigravity@vibee.org> Co-authored-by: Claude <noreply@anthropic.com>
1 parent caf0b6b commit b87f6aa

8 files changed

Lines changed: 731 additions & 0 deletions

File tree

build.zig

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,19 @@ pub fn build(b: *std.Build) void {
177177
const run_vm_tests = b.addRunArtifact(vm_tests);
178178
test_step.dependOn(&run_vm_tests.step);
179179

180+
// E2E + Benchmarks + Verdict tests (Phase 4)
181+
const e2e_tests = b.addTest(.{
182+
.root_module = b.createModule(.{
183+
.root_source_file = b.path("src/e2e_test.zig"),
184+
.target = target,
185+
.optimize = optimize,
186+
}),
187+
});
188+
const run_e2e_tests = b.addRunArtifact(e2e_tests);
189+
test_step.dependOn(&run_e2e_tests.step);
190+
const e2e_step = b.step("e2e", "Run E2E tests + benchmarks + verdict");
191+
e2e_step.dependOn(&run_e2e_tests.step);
192+
180193
// C API tests (libtrinity-vsa)
181194
const c_api_tests = b.addTest(.{
182195
.root_module = b.createModule(.{
@@ -1337,6 +1350,22 @@ pub fn build(b: *std.Build) void {
13371350
});
13381351
b.installArtifact(ralph_hook);
13391352

1353+
// TRI BOT — Telegram bot as Claude Code CLI remote control
1354+
const tri_bot = b.addExecutable(.{
1355+
.name = "tri-bot",
1356+
.root_module = b.createModule(.{
1357+
.root_source_file = b.path("tools/mcp/trinity_mcp/bot/main.zig"),
1358+
.target = target,
1359+
.optimize = optimize,
1360+
}),
1361+
});
1362+
b.installArtifact(tri_bot);
1363+
1364+
const run_tri_bot = b.addRunArtifact(tri_bot);
1365+
if (b.args) |args| run_tri_bot.addArgs(args);
1366+
const tri_bot_step = b.step("tri-bot", "Run TRI BOT \xe2\x80\x94 Telegram Claude Code remote control");
1367+
tri_bot_step.dependOn(&run_tri_bot.step);
1368+
13401369
// ═══════════════════════════════════════════════════════════════════════════
13411370
// PHI LOOP — 999 Links of Cosmic Consciousness Gene
13421371
const phi_loop = b.addExecutable(.{

specs/tri/tri_bot.vibee

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
name: tri_bot
2+
version: "1.0.0"
3+
language: zig
4+
module: tri_bot
5+
6+
description: |
7+
TRI BOT — Telegram bot as Claude Code CLI remote control.
8+
Receives commands via getUpdates long polling, dispatches to claude CLI,
9+
streams responses back via sendMessage / sendMessageDraft.
10+
11+
constants:
12+
POLL_TIMEOUT: 30
13+
MAX_MESSAGE_LEN: 4096
14+
MAX_RESPONSE_BUF: 1048576
15+
STREAM_CHUNK_INTERVAL_MS: 500
16+
17+
types:
18+
BotConfig:
19+
fields:
20+
bot_token: String
21+
chat_id: String
22+
project_root: String
23+
max_turns: Int
24+
model: Option<String>
25+
26+
BotState:
27+
fields:
28+
last_update_id: Int
29+
current_session_id: Option<String>
30+
current_model: Option<String>
31+
is_busy: Bool
32+
33+
TelegramUpdate:
34+
fields:
35+
update_id: Int
36+
chat_id: Int
37+
text: String
38+
39+
Command:
40+
fields:
41+
name: String
42+
args: String
43+
44+
behaviors:
45+
- name: poll_updates
46+
given: A valid BotConfig with bot_token
47+
when: Bot polls Telegram getUpdates with timeout and offset
48+
then: Returns list of TelegramUpdate or empty on timeout
49+
50+
- name: parse_command
51+
given: A TelegramUpdate with text starting with /
52+
when: Text is parsed for command name and arguments
53+
then: Returns a Command struct with name and args separated
54+
55+
- name: dispatch_command
56+
given: A parsed Command and current BotState
57+
when: Command is matched against known commands
58+
then: Executes the appropriate handler and returns response
59+
60+
- name: handle_ask
61+
given: Command /ask with a question as args
62+
when: Spawns claude -p with the question
63+
then: Sends Claude response to Telegram
64+
65+
- name: handle_continue
66+
given: Command /continue with optional question
67+
when: Spawns claude -p with --continue flag
68+
then: Sends Claude response continuing previous session
69+
70+
- name: handle_status
71+
given: Command /status with no args
72+
when: Spawns claude -p with status query
73+
then: Sends formatted project status to Telegram
74+
75+
- name: handle_stop
76+
given: Command /stop while a Claude process is running
77+
when: Kills the active child process
78+
then: Sends confirmation message
79+
80+
- name: handle_help
81+
given: Command /help
82+
when: Bot receives help request
83+
then: Sends list of available commands
84+
85+
- name: run_bot_loop
86+
given: Initialized BotConfig and BotState
87+
when: Main loop starts
88+
then: Polls updates, parses commands, dispatches handlers, repeats
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// bot_loop.zig — Main poll → parse → dispatch → repeat loop
2+
const std = @import("std");
3+
const telegram_api = @import("telegram_api.zig");
4+
const json_utils = @import("json_utils.zig");
5+
const command_parser = @import("command_parser.zig");
6+
const handlers = @import("handlers.zig");
7+
8+
const BotConfig = telegram_api.BotConfig;
9+
10+
/// Run the bot loop: poll Telegram, parse commands, dispatch handlers.
11+
/// Never returns (infinite loop).
12+
pub fn run(allocator: std.mem.Allocator, config: BotConfig) void {
13+
var last_update_id: i64 = 0;
14+
15+
// Announce startup
16+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xf0\x9f\xa4\x96 TRI BOT online! Send /help for commands.");
17+
18+
std.debug.print("[tri-bot] Started. Polling Telegram...\n", .{});
19+
20+
while (true) {
21+
const body = telegram_api.getUpdates(allocator, config.bot_token, last_update_id + 1) orelse {
22+
// Network error — wait and retry
23+
std.Thread.sleep(5 * std.time.ns_per_s);
24+
continue;
25+
};
26+
defer allocator.free(body);
27+
28+
// Process each update
29+
const Context = struct {
30+
allocator: std.mem.Allocator,
31+
config: BotConfig,
32+
max_id: i64,
33+
};
34+
var ctx = Context{
35+
.allocator = allocator,
36+
.config = config,
37+
.max_id = last_update_id,
38+
};
39+
40+
// We can't use closures in Zig, so use a global-style dispatch.
41+
// Instead, manually iterate updates with a simple loop.
42+
processUpdates(allocator, config, body, &ctx.max_id);
43+
last_update_id = ctx.max_id;
44+
}
45+
}
46+
47+
fn processUpdates(allocator: std.mem.Allocator, config: BotConfig, body: []const u8, max_id: *i64) void {
48+
// Find each "update_id": block manually
49+
var pos: usize = 0;
50+
while (pos < body.len) {
51+
const needle = "\"update_id\":";
52+
const idx = std.mem.indexOfPos(u8, body, pos, needle) orelse break;
53+
54+
// Determine block boundary (next update_id or end)
55+
const next_idx = std.mem.indexOfPos(u8, body, idx + needle.len + 1, needle) orelse body.len;
56+
const block = body[idx..next_idx];
57+
58+
// Extract update_id
59+
const uid = json_utils.extractInt(block, "update_id") orelse {
60+
pos = idx + needle.len;
61+
continue;
62+
};
63+
64+
// Update max
65+
if (uid > max_id.*) {
66+
max_id.* = uid;
67+
}
68+
69+
// Extract chat_id
70+
const chat_id_val = blk: {
71+
const chat_needle = "\"chat\":{\"id\":";
72+
const ci = std.mem.indexOf(u8, block, chat_needle) orelse break :blk @as(i64, 0);
73+
const cs = ci + chat_needle.len;
74+
var ce = cs;
75+
while (ce < block.len and ((block[ce] >= '0' and block[ce] <= '9') or block[ce] == '-')) : (ce += 1) {}
76+
break :blk std.fmt.parseInt(i64, block[cs..ce], 10) catch 0;
77+
};
78+
79+
// Auth check: only respond to configured chat_id
80+
const expected_chat_id = std.fmt.parseInt(i64, config.chat_id, 10) catch 0;
81+
if (chat_id_val != expected_chat_id) {
82+
std.debug.print("[tri-bot] Ignoring update from chat {d} (expected {d})\n", .{ chat_id_val, expected_chat_id });
83+
pos = next_idx;
84+
continue;
85+
}
86+
87+
// Extract text
88+
const text = json_utils.extractString(block, "text") orelse {
89+
pos = next_idx;
90+
continue;
91+
};
92+
93+
std.debug.print("[tri-bot] Update {d}: \"{s}\"\n", .{ uid, text });
94+
95+
// Parse command
96+
const cmd = command_parser.parse(text);
97+
98+
// Dispatch
99+
dispatch(allocator, config, cmd);
100+
101+
pos = next_idx;
102+
}
103+
}
104+
105+
fn dispatch(allocator: std.mem.Allocator, config: BotConfig, cmd: command_parser.Command) void {
106+
if (std.mem.eql(u8, cmd.name, "help")) {
107+
handlers.handleHelp(allocator, config);
108+
} else if (std.mem.eql(u8, cmd.name, "ask")) {
109+
handlers.handleAsk(allocator, config, cmd.args);
110+
} else if (std.mem.eql(u8, cmd.name, "continue")) {
111+
handlers.handleContinue(allocator, config, cmd.args);
112+
} else if (std.mem.eql(u8, cmd.name, "status")) {
113+
handlers.handleStatus(allocator, config);
114+
} else if (std.mem.eql(u8, cmd.name, "stop")) {
115+
telegram_api.sendMessage(allocator, config.bot_token, config.chat_id, "\xe2\x9b\x94 /stop not yet implemented (Phase 2)");
116+
} else if (cmd.name.len > 0) {
117+
// Unknown command
118+
var buf: [256]u8 = undefined;
119+
telegram_api.sendFmt(allocator, config.bot_token, config.chat_id, &buf, "\xe2\x9d\x93 Unknown command: /{s}. Try /help", .{cmd.name});
120+
}
121+
// Plain text (no command) — ignore silently
122+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// command_parser.zig — Parse /command args from Telegram message text
2+
const std = @import("std");
3+
4+
pub const Command = struct {
5+
name: []const u8, // e.g. "ask", "status", "help"
6+
args: []const u8, // everything after the command name
7+
};
8+
9+
/// Parse a Telegram message into a Command.
10+
/// "/ask what is this project" → {name:"ask", args:"what is this project"}
11+
/// "hello" → {name:"", args:"hello"} (not a command)
12+
pub fn parse(text: []const u8) Command {
13+
if (text.len == 0) return .{ .name = "", .args = "" };
14+
if (text[0] != '/') return .{ .name = "", .args = text };
15+
16+
// Skip the /
17+
const after_slash = text[1..];
18+
19+
// Find end of command name (space or @botname or end)
20+
var name_end: usize = 0;
21+
while (name_end < after_slash.len) : (name_end += 1) {
22+
const c = after_slash[name_end];
23+
if (c == ' ' or c == '@') break;
24+
}
25+
26+
const name = after_slash[0..name_end];
27+
28+
// Skip spaces after command
29+
var args_start = name_end;
30+
// Skip @botname if present
31+
if (args_start < after_slash.len and after_slash[args_start] == '@') {
32+
while (args_start < after_slash.len and after_slash[args_start] != ' ') : (args_start += 1) {}
33+
}
34+
while (args_start < after_slash.len and after_slash[args_start] == ' ') : (args_start += 1) {}
35+
36+
const args = if (args_start < after_slash.len) after_slash[args_start..] else "";
37+
38+
return .{ .name = name, .args = args };
39+
}
40+
41+
test "parse /ask with args" {
42+
const cmd = parse("/ask what is this project");
43+
try std.testing.expectEqualStrings("ask", cmd.name);
44+
try std.testing.expectEqualStrings("what is this project", cmd.args);
45+
}
46+
47+
test "parse /help no args" {
48+
const cmd = parse("/help");
49+
try std.testing.expectEqualStrings("help", cmd.name);
50+
try std.testing.expectEqualStrings("", cmd.args);
51+
}
52+
53+
test "parse /ask@bot_name with args" {
54+
const cmd = parse("/ask@trinity_bot hello world");
55+
try std.testing.expectEqualStrings("ask", cmd.name);
56+
try std.testing.expectEqualStrings("hello world", cmd.args);
57+
}
58+
59+
test "parse plain text" {
60+
const cmd = parse("hello world");
61+
try std.testing.expectEqualStrings("", cmd.name);
62+
try std.testing.expectEqualStrings("hello world", cmd.args);
63+
}
64+
65+
test "parse empty" {
66+
const cmd = parse("");
67+
try std.testing.expectEqualStrings("", cmd.name);
68+
try std.testing.expectEqualStrings("", cmd.args);
69+
}

0 commit comments

Comments
 (0)