KeiSeiKit-1.0/_primitives/_rust/kei-notify-telegram/src/channel.rs
Parfii-bot 0be354a920 KeiSeiKit-public — clean state
Single-commit clean baseline after security scrub of niche-tells,
project codenames, internal jargon, and contributor-email leaks.

Contents:
- 100 Rust crates (_primitives/_rust/)
- 37 agent manifests (_manifests/) + generated specs (_generated/)
- 67 user-invocable skills (skills/)
- 33 hooks (hooks/)
- Composition blocks (_blocks/)
- Documentation (docs/, README.md)
- TS adapter packages (_ts_packages/)
- Assembler (_assembler/)
- Roles (_roles/)
- Templates (_templates/)
- Forgejo CI (.forgejo/)

Author: Denis Parfionovich <info@greendragon.info>

License: see LICENSE.
2026-05-01 12:09:03 +08:00

134 lines
5 KiB
Rust

// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 <author org>
//!
//! `TelegramChannel` — `NotifyChannel` impl that POSTs to the
//! Telegram Bot API `sendMessage` endpoint.
//!
//! Constructor surface:
//! * [`TelegramChannel::from_env`] — reads `TELEGRAM_BOT_TOKEN`
//! + `TELEGRAM_CHAT_ID`, defaults base URL to
//! `https://api.telegram.org`.
//! * [`TelegramChannel::with_config`] — explicit base URL, token,
//! chat_id (used in wiremock tests).
//!
//! Wire format: `POST {base}/bot{token}/sendMessage` with JSON
//! `{"chat_id": <i64|String>, "text": "...", "parse_mode": "HTML"}`.
//! Response is `{"ok": true, "result": {...}}` on success or
//! `{"ok": false, "description": "..."}` on failure — the latter is
//! surfaced as `Error::Api(description)`.
use crate::error::{Error, Result};
use crate::payload::build_text;
use kei_runtime_core::traits::notify::{Notification, NotifyChannel, NotifySeverity};
use kei_runtime_core::{Dna, DnaBuilder, HasDna};
use serde::Deserialize;
use serde_json::{json, Value};
const DEFAULT_BASE_URL: &str = "https://api.telegram.org";
pub struct TelegramChannel {
dna: Dna,
parent: Option<Dna>,
http: reqwest::Client,
base_url: String,
bot_token: String,
chat_id: String,
}
impl TelegramChannel {
/// Build a channel from explicit base URL + bot token + chat id.
/// `base_url` is trimmed of trailing slashes so callers may pass
/// either `https://api.telegram.org` or `.../`.
pub fn with_config(
base_url: impl Into<String>,
bot_token: impl Into<String>,
chat_id: impl Into<String>,
parent: Option<Dna>,
) -> Result<Self> {
let dna = DnaBuilder::new("primitive")
.caps(["PR", "AP", "TG"])
.scope("keiseikit.dev/primitives/kei-notify-telegram")
.body(b"telegram-bot-v1")
.build()?;
let base_url = base_url.into().trim_end_matches('/').to_string();
Ok(Self {
dna,
parent,
http: reqwest::Client::new(),
base_url,
bot_token: bot_token.into(),
chat_id: chat_id.into(),
})
}
/// Build a channel from env vars.
/// Required: `TELEGRAM_BOT_TOKEN`, `TELEGRAM_CHAT_ID`.
/// Optional: `TELEGRAM_API_BASE_URL` (default `https://api.telegram.org`).
pub fn from_env(parent: Option<Dna>) -> Result<Self> {
let token = std::env::var("TELEGRAM_BOT_TOKEN")
.map_err(|_| Error::MissingEnv("TELEGRAM_BOT_TOKEN".into()))?;
let chat_id = std::env::var("TELEGRAM_CHAT_ID")
.map_err(|_| Error::MissingEnv("TELEGRAM_CHAT_ID".into()))?;
let base_url = std::env::var("TELEGRAM_API_BASE_URL")
.unwrap_or_else(|_| DEFAULT_BASE_URL.to_string());
Self::with_config(base_url, token, chat_id, parent)
}
pub fn base_url(&self) -> &str { &self.base_url }
pub fn chat_id(&self) -> &str { &self.chat_id }
/// Render `chat_id` as JSON: numeric `i64` if it parses, else `String`
/// (covers the `@channel_username` form Telegram accepts).
fn chat_id_value(&self) -> Value {
match self.chat_id.parse::<i64>() {
Ok(n) => Value::from(n),
Err(_) => Value::String(self.chat_id.clone()),
}
}
}
impl HasDna for TelegramChannel {
fn dna(&self) -> &Dna { &self.dna }
fn parent_dna(&self) -> Option<&Dna> { self.parent.as_ref() }
}
#[derive(Debug, Deserialize)]
struct ApiResponse {
ok: bool,
#[serde(default)]
description: Option<String>,
}
#[async_trait::async_trait]
impl NotifyChannel for TelegramChannel {
fn channel_name(&self) -> &'static str { "telegram" }
fn supports_batching(&self) -> bool { false }
fn min_severity(&self) -> NotifySeverity { NotifySeverity::Info }
async fn send(&self, n: &Notification) -> kei_runtime_core::Result<()> {
let url = format!("{}/bot{}/sendMessage", self.base_url, self.bot_token);
let body = json!({
"chat_id": self.chat_id_value(),
"text": build_text(n),
"parse_mode": "HTML",
});
let resp = self.http.post(&url).json(&body).send().await
.map_err(Error::from).map_err(kei_runtime_core::Error::from)?;
let status = resp.status();
if !status.is_success() {
// 5xx / 4xx that did not produce a JSON body — surface as Api error
// with the raw text so debugging is possible without a packet capture.
let text = resp.text().await.unwrap_or_default();
return Err(kei_runtime_core::Error::from(Error::Api(format!(
"http {} body {}", status.as_u16(), text
))));
}
let parsed: ApiResponse = resp.json().await
.map_err(Error::from).map_err(kei_runtime_core::Error::from)?;
if !parsed.ok {
let desc = parsed.description.unwrap_or_else(|| "no description".into());
return Err(kei_runtime_core::Error::from(Error::Api(desc)));
}
Ok(())
}
}