All checks were successful
ci/woodpecker/push/deploy Pipeline was successful
- /speak <voice> enables TTS with selected voice (irina, denis, dmitri) - /speak stop disables TTS in the chat - Private chats keep always-on TTS behavior - Add PIPER_VOICES env var for voice-to-URL mapping - Add chat type field to distinguish private/group chats Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
173 lines
5.2 KiB
Zig
173 lines
5.2 KiB
Zig
const std = @import("std");
|
|
const http = @import("http.zig");
|
|
const Allocator = std.mem.Allocator;
|
|
|
|
pub const Voice = struct {
|
|
file_id: []const u8,
|
|
duration: ?i64 = null,
|
|
};
|
|
|
|
pub const VideoNote = struct {
|
|
file_id: []const u8,
|
|
duration: ?i64 = null,
|
|
};
|
|
|
|
pub const Message = struct {
|
|
message_id: i64,
|
|
chat: struct {
|
|
id: i64,
|
|
@"type": []const u8 = "private",
|
|
},
|
|
text: ?[]const u8 = null,
|
|
voice: ?Voice = null,
|
|
video_note: ?VideoNote = null,
|
|
};
|
|
|
|
pub const Update = struct {
|
|
update_id: i64,
|
|
message: ?Message = null,
|
|
};
|
|
|
|
pub const GetUpdatesResponse = struct {
|
|
ok: bool,
|
|
result: []Update = &.{},
|
|
};
|
|
|
|
pub const GetFileResponse = struct {
|
|
ok: bool,
|
|
result: ?struct {
|
|
file_path: ?[]const u8 = null,
|
|
} = null,
|
|
};
|
|
|
|
pub const SendMessageBody = struct {
|
|
chat_id: i64,
|
|
text: []const u8,
|
|
reply_to_message_id: ?i64 = null,
|
|
};
|
|
|
|
/// Parsed JSON result that also owns the raw JSON body.
|
|
/// Must call deinit() to free both.
|
|
pub fn OwnedParsed(comptime T: type) type {
|
|
return struct {
|
|
parsed: std.json.Parsed(T),
|
|
raw_body: []u8,
|
|
allocator: Allocator,
|
|
|
|
pub fn deinit(self: *@This()) void {
|
|
self.parsed.deinit();
|
|
self.allocator.free(self.raw_body);
|
|
}
|
|
};
|
|
}
|
|
|
|
pub const TelegramBot = struct {
|
|
allocator: Allocator,
|
|
token: []const u8,
|
|
api_base: []const u8,
|
|
|
|
pub fn init(allocator: Allocator, token: []const u8) !TelegramBot {
|
|
const api_base = try std.fmt.allocPrint(allocator, "https://api.telegram.org/bot{s}", .{token});
|
|
return .{
|
|
.allocator = allocator,
|
|
.token = token,
|
|
.api_base = api_base,
|
|
};
|
|
}
|
|
|
|
pub fn deinit(self: *TelegramBot) void {
|
|
self.allocator.free(self.api_base);
|
|
}
|
|
|
|
pub fn getUpdates(self: *TelegramBot, offset: i64, timeout: u32) !OwnedParsed(GetUpdatesResponse) {
|
|
const url = try std.fmt.allocPrint(self.allocator, "{s}/getUpdates?offset={d}&timeout={d}", .{ self.api_base, offset, timeout });
|
|
defer self.allocator.free(url);
|
|
|
|
const body = try http.httpGet(self.allocator, url);
|
|
|
|
const parsed = std.json.parseFromSlice(GetUpdatesResponse, self.allocator, body, .{ .ignore_unknown_fields = true }) catch {
|
|
self.allocator.free(body);
|
|
return error.HttpRequestFailed;
|
|
};
|
|
|
|
return .{
|
|
.parsed = parsed,
|
|
.raw_body = body,
|
|
.allocator = self.allocator,
|
|
};
|
|
}
|
|
|
|
pub fn getFilePath(self: *TelegramBot, file_id: []const u8) ![]u8 {
|
|
const url = try std.fmt.allocPrint(self.allocator, "{s}/getFile?file_id={s}", .{ self.api_base, file_id });
|
|
defer self.allocator.free(url);
|
|
|
|
const body = try http.httpGet(self.allocator, url);
|
|
defer self.allocator.free(body);
|
|
|
|
const parsed = try std.json.parseFromSlice(GetFileResponse, self.allocator, body, .{ .ignore_unknown_fields = true });
|
|
defer parsed.deinit();
|
|
|
|
if (parsed.value.result) |result| {
|
|
if (result.file_path) |fp| {
|
|
return self.allocator.dupe(u8, fp);
|
|
}
|
|
}
|
|
return error.HttpRequestFailed;
|
|
}
|
|
|
|
pub fn downloadFile(self: *TelegramBot, file_path: []const u8, dest: []const u8) !void {
|
|
const url = try std.fmt.allocPrint(self.allocator, "https://api.telegram.org/file/bot{s}/{s}", .{ self.token, file_path });
|
|
defer self.allocator.free(url);
|
|
|
|
try http.downloadToFile(self.allocator, url, dest);
|
|
}
|
|
|
|
pub fn sendMessage(self: *TelegramBot, chat_id: i64, text: []const u8, reply_to: ?i64) !void {
|
|
const url = try std.fmt.allocPrint(self.allocator, "{s}/sendMessage", .{self.api_base});
|
|
defer self.allocator.free(url);
|
|
|
|
const msg = SendMessageBody{
|
|
.chat_id = chat_id,
|
|
.text = text,
|
|
.reply_to_message_id = reply_to,
|
|
};
|
|
|
|
const json_body = std.json.Stringify.valueAlloc(self.allocator, msg, .{}) catch return;
|
|
defer self.allocator.free(json_body);
|
|
|
|
const resp = http.httpPostJson(self.allocator, url, json_body) catch return;
|
|
self.allocator.free(resp);
|
|
}
|
|
|
|
pub fn sendVoice(self: *TelegramBot, chat_id: i64, ogg_path: []const u8, reply_to: ?i64) !void {
|
|
const url = try std.fmt.allocPrint(self.allocator, "{s}/sendVoice", .{self.api_base});
|
|
defer self.allocator.free(url);
|
|
|
|
const chat_id_str = try std.fmt.allocPrint(self.allocator, "{d}", .{chat_id});
|
|
defer self.allocator.free(chat_id_str);
|
|
|
|
var fields_buf: [2][2][]const u8 = undefined;
|
|
var field_count: usize = 1;
|
|
fields_buf[0] = .{ "chat_id", chat_id_str };
|
|
|
|
var reply_str: ?[]u8 = null;
|
|
defer if (reply_str) |s| self.allocator.free(s);
|
|
|
|
if (reply_to) |r| {
|
|
reply_str = try std.fmt.allocPrint(self.allocator, "{d}", .{r});
|
|
fields_buf[1] = .{ "reply_to_message_id", reply_str.? };
|
|
field_count = 2;
|
|
}
|
|
|
|
const resp = try http.httpPostMultipart(
|
|
self.allocator,
|
|
url,
|
|
"voice",
|
|
ogg_path,
|
|
"voice.ogg",
|
|
fields_buf[0..field_count],
|
|
);
|
|
self.allocator.free(resp);
|
|
}
|
|
};
|