mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-01 09:09:19 +00:00
feat: Add Google Suite & Telegram WASM tools (#9)
* Add Google Calendar and Gmail WASM tools, and /add-tool skill Scaffold two new WASM tools that share a single Google OAuth token: - google-calendar: list/get/create/update/delete calendar events - gmail: list/search/get/send/draft/reply/trash emails Both tools use the sandboxed WIT interface with strict HTTP allowlists, credential injection, and rate limiting. OAuth config requests only the minimum scopes needed (calendar.events, gmail.modify, gmail.compose). Also adds the /add-tool skill for scaffolding future WASM or built-in tools with all boilerplate wired up. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Document WASM vs MCP server decision guide in CLAUDE.md Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Drive WASM tool with full file and sharing management Supports 12 actions: list/get/download/upload/update files, create folders, delete/trash, share/list/remove permissions, and list shared drives. Works with both personal and organizational drives via the corpora parameter. Uses shared google_oauth_token for auth. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Sheets, Docs, and Slides WASM tools Three new Google Workspace tools sharing google_oauth_token: - Sheets: create spreadsheets, read/write/append values, manage sheets, format cells - Docs: create/read/edit documents, text formatting, paragraphs, tables, lists - Slides: create/edit presentations, shapes, images, text formatting, thumbnails, templates Also adds tools-src/TOOLS.md tracking implementation status. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Telegram WASM tool with direct MTProto over HTTPS Replace TDLight Docker dependency with pure-Rust grammers crates for direct encrypted MTProto communication to Telegram's web transport endpoints. No middleware, no Docker needed. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Gitignore Cargo.lock files in WASM tools Library crates should not commit lock files. Consolidate per-tool .gitignore into a single one at wasm-tools/ level. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Flatten tools-src/wasm-tools/ into tools-src/ All tools are WASM, the extra nesting added no value. Moves all tool crates up one level, updates WIT paths and documentation references. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix Slack tool: add OAuth auth, URL encoding, pin wit-bindgen - Add OAuth 2.0 auth section to Slack capabilities with proper scopes and manual fallback instructions - URL-encode query parameters in GET requests to prevent injection - Remove dead SlackApiError struct - Pin wit-bindgen to =0.36 across all WASM tools for Rust 1.86 compat - Update add-tool template with pinned version Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e6725eb6d9
commit
a35db4d32d
@@ -0,0 +1,705 @@
|
||||
//! Telegram MTProto API implementation.
|
||||
//!
|
||||
//! Sends encrypted RPC requests directly to Telegram's data centers via
|
||||
//! HTTP POST to `https://{dc}.web.telegram.org/apiw`. Uses grammers-mtproto
|
||||
//! (Sans-IO) for message framing and encryption; no TDLib/TDLight needed.
|
||||
|
||||
use grammers_mtproto::mtp::Encrypted;
|
||||
use grammers_tl_types::{self as tl, Deserializable, Serializable};
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::transport;
|
||||
use crate::types::*;
|
||||
|
||||
/// Current TL layer. Must match grammers-tl-types.
|
||||
const LAYER: i32 = 185;
|
||||
|
||||
/// Wrap a request in InvokeWithLayer + InitConnection for the first RPC.
|
||||
///
|
||||
/// Telegram requires the first request in a session to be wrapped in
|
||||
/// initConnection so the server knows our client metadata.
|
||||
fn wrap_init_connection(session: &Session, inner_bytes: Vec<u8>) -> Vec<u8> {
|
||||
let init = tl::functions::InitConnection {
|
||||
api_id: session.api_id,
|
||||
device_model: "WASM Sandbox".to_string(),
|
||||
system_version: "wasip2".to_string(),
|
||||
app_version: "0.1.0".to_string(),
|
||||
system_lang_code: "en".to_string(),
|
||||
lang_pack: String::new(),
|
||||
lang_code: "en".to_string(),
|
||||
proxy: None,
|
||||
params: None,
|
||||
query: inner_bytes,
|
||||
};
|
||||
|
||||
tl::functions::InvokeWithLayer {
|
||||
layer: LAYER,
|
||||
query: init.to_bytes(),
|
||||
}
|
||||
.to_bytes()
|
||||
}
|
||||
|
||||
/// Create an Encrypted MTP instance from session state.
|
||||
fn make_mtp(session: &Session) -> Result<Encrypted, String> {
|
||||
let auth_key = session.auth_key_bytes()?;
|
||||
Ok(Encrypted::build()
|
||||
.time_offset(session.time_offset)
|
||||
.first_salt(session.first_salt)
|
||||
.finish(auth_key))
|
||||
}
|
||||
|
||||
/// Send an encrypted RPC, wrapping in initConnection on first call.
|
||||
fn rpc_call(
|
||||
mtp: &mut Encrypted,
|
||||
session: &Session,
|
||||
request_bytes: Vec<u8>,
|
||||
init_wrap: bool,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let bytes = if init_wrap {
|
||||
wrap_init_connection(session, request_bytes)
|
||||
} else {
|
||||
request_bytes
|
||||
};
|
||||
transport::post_encrypted(mtp, session.dc_id, &bytes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Login flow
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Send auth code to phone number.
|
||||
pub fn send_code(session: &mut Session) -> Result<String, String> {
|
||||
let phone = session
|
||||
.phone_number
|
||||
.as_ref()
|
||||
.ok_or("phone_number not set in session")?
|
||||
.clone();
|
||||
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let request = tl::functions::auth::SendCode {
|
||||
phone_number: phone,
|
||||
api_id: session.api_id,
|
||||
api_hash: session.api_hash.clone(),
|
||||
settings: tl::enums::CodeSettings::Settings(tl::types::CodeSettings {
|
||||
allow_flashcall: false,
|
||||
current_number: false,
|
||||
allow_app_hash: false,
|
||||
allow_missed_call: false,
|
||||
allow_firebase: false,
|
||||
unknown_number: false,
|
||||
logout_tokens: None,
|
||||
token: None,
|
||||
app_sandbox: None,
|
||||
}),
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let sent = tl::enums::auth::SentCode::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse SentCode: {e}"))?;
|
||||
|
||||
match sent {
|
||||
tl::enums::auth::SentCode::Code(code) => {
|
||||
session.phone_code_hash = Some(code.phone_code_hash.clone());
|
||||
Ok(serde_json::to_string(&LoginResult {
|
||||
status: "code_sent".into(),
|
||||
phone_code_hash: Some(code.phone_code_hash),
|
||||
message: Some(
|
||||
"Verification code sent. Use submit_auth_code to complete login.".into(),
|
||||
),
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
tl::enums::auth::SentCode::Success(_) => {
|
||||
session.logged_in = true;
|
||||
Ok(serde_json::to_string(&LoginResult {
|
||||
status: "logged_in".into(),
|
||||
phone_code_hash: None,
|
||||
message: Some("Already logged in.".into()),
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
tl::enums::auth::SentCode::PaymentRequired(_) => {
|
||||
Err("Telegram requires payment to send auth codes to this number.".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete login with the verification code.
|
||||
pub fn sign_in(session: &mut Session, code: &str) -> Result<String, String> {
|
||||
let phone = session
|
||||
.phone_number
|
||||
.as_ref()
|
||||
.ok_or("phone_number not set, call login first")?
|
||||
.clone();
|
||||
let hash = session
|
||||
.phone_code_hash
|
||||
.as_ref()
|
||||
.ok_or("phone_code_hash not set, call login first")?
|
||||
.clone();
|
||||
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let request = tl::functions::auth::SignIn {
|
||||
phone_number: phone,
|
||||
phone_code_hash: hash,
|
||||
phone_code: Some(code.to_string()),
|
||||
email_verification: None,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
|
||||
match tl::enums::auth::Authorization::from_bytes(&resp_bytes) {
|
||||
Ok(tl::enums::auth::Authorization::Authorization(auth)) => {
|
||||
session.logged_in = true;
|
||||
session.phone_code_hash = None;
|
||||
Ok(format_user_auth(&auth.user))
|
||||
}
|
||||
Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => {
|
||||
Err("Account not registered. Sign up on a Telegram client first.".into())
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"signIn failed (maybe 2FA required): {e}. \
|
||||
If you have 2FA enabled, use submit_2fa_password."
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Submit 2FA password using SRP protocol.
|
||||
pub fn check_password(session: &mut Session, password: &str) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
|
||||
// Get the current password info (SRP parameters).
|
||||
let request = tl::functions::account::GetPassword {}.to_bytes();
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let pwd = tl::enums::account::Password::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse Password: {e}"))?;
|
||||
|
||||
let tl::enums::account::Password::Password(pwd) = pwd;
|
||||
|
||||
let current_algo = pwd
|
||||
.current_algo
|
||||
.ok_or("no current_algo, 2FA might not be enabled")?;
|
||||
|
||||
let srp_b = pwd.srp_b.ok_or("no srp_B in password response")?;
|
||||
let srp_id = pwd.srp_id.ok_or("no srp_id in password response")?;
|
||||
|
||||
match current_algo {
|
||||
tl::enums::PasswordKdfAlgo::Sha256Sha256Pbkdf2Hmacsha512iter100000Sha256ModPow(algo) => {
|
||||
let mut a_bytes = vec![0u8; 256];
|
||||
getrandom::fill(&mut a_bytes).map_err(|e| format!("getrandom failed: {e}"))?;
|
||||
|
||||
let (m1, g_a) = grammers_crypto::two_factor_auth::calculate_2fa(
|
||||
&algo.salt1,
|
||||
&algo.salt2,
|
||||
&algo.p,
|
||||
&algo.g,
|
||||
srp_b,
|
||||
a_bytes,
|
||||
password.as_bytes(),
|
||||
);
|
||||
|
||||
let check_req = tl::functions::auth::CheckPassword {
|
||||
password: tl::enums::InputCheckPasswordSrp::Srp(tl::types::InputCheckPasswordSrp {
|
||||
srp_id,
|
||||
a: g_a.to_vec(),
|
||||
m1: m1.to_vec(),
|
||||
}),
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, check_req, false)?;
|
||||
match tl::enums::auth::Authorization::from_bytes(&resp_bytes) {
|
||||
Ok(tl::enums::auth::Authorization::Authorization(auth)) => {
|
||||
session.logged_in = true;
|
||||
session.phone_code_hash = None;
|
||||
Ok(format_user_auth(&auth.user))
|
||||
}
|
||||
Ok(tl::enums::auth::Authorization::SignUpRequired(_)) => {
|
||||
Err("Unexpected sign-up required after 2FA".into())
|
||||
}
|
||||
Err(e) => Err(format!("2FA check failed: {e}")),
|
||||
}
|
||||
}
|
||||
tl::enums::PasswordKdfAlgo::Unknown => {
|
||||
Err("server returned unknown password KDF algorithm; client may be outdated".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Read-only API methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn get_me(session: &Session) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let request = tl::functions::users::GetFullUser {
|
||||
id: tl::enums::InputUser::UserSelf,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let full = tl::enums::users::UserFull::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse UserFull: {e}"))?;
|
||||
|
||||
let tl::enums::users::UserFull::Full(full) = full;
|
||||
|
||||
for user_enum in &full.users {
|
||||
if let tl::enums::User::User(u) = user_enum {
|
||||
return Ok(serde_json::to_string(&UserInfo {
|
||||
id: u.id,
|
||||
first_name: u.first_name.clone().unwrap_or_default(),
|
||||
last_name: u.last_name.clone(),
|
||||
username: u.username.clone(),
|
||||
phone_number: u.phone.clone(),
|
||||
})
|
||||
.unwrap_or_default());
|
||||
}
|
||||
}
|
||||
Err("no user in response".into())
|
||||
}
|
||||
|
||||
pub fn get_contacts(session: &Session) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let request = tl::functions::contacts::GetContacts { hash: 0 }.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let contacts = tl::enums::contacts::Contacts::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse Contacts: {e}"))?;
|
||||
|
||||
match contacts {
|
||||
tl::enums::contacts::Contacts::Contacts(c) => {
|
||||
let users: Vec<UserInfo> = c
|
||||
.users
|
||||
.iter()
|
||||
.filter_map(|u| match u {
|
||||
tl::enums::User::User(u) => Some(UserInfo {
|
||||
id: u.id,
|
||||
first_name: u.first_name.clone().unwrap_or_default(),
|
||||
last_name: u.last_name.clone(),
|
||||
username: u.username.clone(),
|
||||
phone_number: u.phone.clone(),
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
Ok(serde_json::to_string(&users).unwrap_or_default())
|
||||
}
|
||||
tl::enums::contacts::Contacts::NotModified => Ok("[]".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_chats(session: &Session, limit: i32) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let request = tl::functions::messages::GetDialogs {
|
||||
exclude_pinned: false,
|
||||
folder_id: None,
|
||||
offset_date: 0,
|
||||
offset_id: 0,
|
||||
offset_peer: tl::enums::InputPeer::Empty,
|
||||
limit,
|
||||
hash: 0,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let dialogs = tl::enums::messages::Dialogs::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse Dialogs: {e}"))?;
|
||||
|
||||
let chats = extract_chats_from_dialogs(&dialogs);
|
||||
Ok(serde_json::to_string(&chats).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn get_messages(
|
||||
session: &Session,
|
||||
chat_id: i64,
|
||||
limit: i32,
|
||||
from_message_id: Option<i32>,
|
||||
) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let peer = resolve_peer(chat_id);
|
||||
|
||||
let request = tl::functions::messages::GetHistory {
|
||||
peer,
|
||||
offset_id: from_message_id.unwrap_or(0),
|
||||
offset_date: 0,
|
||||
add_offset: 0,
|
||||
limit,
|
||||
max_id: 0,
|
||||
min_id: 0,
|
||||
hash: 0,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let messages = tl::enums::messages::Messages::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse Messages: {e}"))?;
|
||||
|
||||
let msgs = extract_messages(&messages);
|
||||
Ok(serde_json::to_string(&msgs).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn send_message(session: &Session, chat_id: i64, text: &str) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let peer = resolve_peer(chat_id);
|
||||
|
||||
let mut rng_buf = [0u8; 8];
|
||||
getrandom::fill(&mut rng_buf).map_err(|e| format!("getrandom: {e}"))?;
|
||||
let random_id = i64::from_le_bytes(rng_buf);
|
||||
|
||||
let request = tl::functions::messages::SendMessage {
|
||||
no_webpage: false,
|
||||
silent: false,
|
||||
background: false,
|
||||
clear_draft: false,
|
||||
noforwards: false,
|
||||
update_stickersets_order: false,
|
||||
invert_media: false,
|
||||
allow_paid_floodskip: false,
|
||||
peer,
|
||||
reply_to: None,
|
||||
message: text.to_string(),
|
||||
random_id,
|
||||
reply_markup: None,
|
||||
entities: None,
|
||||
schedule_date: None,
|
||||
send_as: None,
|
||||
quick_reply_shortcut: None,
|
||||
effect: None,
|
||||
allow_paid_stars: None,
|
||||
suggested_post: None,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let result =
|
||||
tl::enums::Updates::from_bytes(&resp_bytes).map_err(|e| format!("parse Updates: {e}"))?;
|
||||
|
||||
match result {
|
||||
tl::enums::Updates::UpdateShortSentMessage(m) => Ok(serde_json::to_string(&SendResult {
|
||||
message_id: m.id,
|
||||
date: m.date,
|
||||
})
|
||||
.unwrap_or_default()),
|
||||
_ => Ok(serde_json::to_string(&SendResult {
|
||||
message_id: 0,
|
||||
date: 0,
|
||||
})
|
||||
.unwrap_or_default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn forward_message(
|
||||
session: &Session,
|
||||
from_chat_id: i64,
|
||||
to_chat_id: i64,
|
||||
message_ids: Vec<i32>,
|
||||
) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let from_peer = resolve_peer(from_chat_id);
|
||||
let to_peer = resolve_peer(to_chat_id);
|
||||
|
||||
let random_ids: Result<Vec<i64>, String> = message_ids
|
||||
.iter()
|
||||
.map(|_| {
|
||||
let mut buf = [0u8; 8];
|
||||
getrandom::fill(&mut buf).map_err(|e| format!("getrandom: {e}"))?;
|
||||
Ok(i64::from_le_bytes(buf))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let request = tl::functions::messages::ForwardMessages {
|
||||
silent: false,
|
||||
background: false,
|
||||
with_my_score: false,
|
||||
drop_author: false,
|
||||
drop_media_captions: false,
|
||||
noforwards: false,
|
||||
allow_paid_floodskip: false,
|
||||
from_peer,
|
||||
id: message_ids,
|
||||
random_id: random_ids?,
|
||||
to_peer,
|
||||
top_msg_id: None,
|
||||
reply_to: None,
|
||||
schedule_date: None,
|
||||
send_as: None,
|
||||
quick_reply_shortcut: None,
|
||||
video_timestamp: None,
|
||||
allow_paid_stars: None,
|
||||
suggested_post: None,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let _updates =
|
||||
tl::enums::Updates::from_bytes(&resp_bytes).map_err(|e| format!("parse Updates: {e}"))?;
|
||||
|
||||
Ok(serde_json::to_string(&ForwardResult { ok: true }).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn delete_messages(
|
||||
session: &Session,
|
||||
message_ids: Vec<i32>,
|
||||
revoke: bool,
|
||||
) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
let request = tl::functions::messages::DeleteMessages {
|
||||
revoke,
|
||||
id: message_ids,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let _affected = tl::enums::messages::AffectedMessages::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse AffectedMessages: {e}"))?;
|
||||
|
||||
Ok(serde_json::to_string(&DeleteResult { ok: true }).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn search_messages(
|
||||
session: &Session,
|
||||
query: &str,
|
||||
chat_id: Option<i64>,
|
||||
limit: i32,
|
||||
) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
|
||||
let request = if let Some(cid) = chat_id {
|
||||
let peer = resolve_peer(cid);
|
||||
tl::functions::messages::Search {
|
||||
peer,
|
||||
q: query.to_string(),
|
||||
from_id: None,
|
||||
saved_peer_id: None,
|
||||
saved_reaction: None,
|
||||
top_msg_id: None,
|
||||
filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty,
|
||||
min_date: 0,
|
||||
max_date: 0,
|
||||
offset_id: 0,
|
||||
add_offset: 0,
|
||||
limit,
|
||||
max_id: 0,
|
||||
min_id: 0,
|
||||
hash: 0,
|
||||
}
|
||||
.to_bytes()
|
||||
} else {
|
||||
tl::functions::messages::SearchGlobal {
|
||||
broadcasts_only: false,
|
||||
groups_only: false,
|
||||
users_only: false,
|
||||
folder_id: None,
|
||||
q: query.to_string(),
|
||||
filter: tl::enums::MessagesFilter::InputMessagesFilterEmpty,
|
||||
min_date: 0,
|
||||
max_date: 0,
|
||||
offset_rate: 0,
|
||||
offset_peer: tl::enums::InputPeer::Empty,
|
||||
offset_id: 0,
|
||||
limit,
|
||||
}
|
||||
.to_bytes()
|
||||
};
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let messages = tl::enums::messages::Messages::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse Messages: {e}"))?;
|
||||
|
||||
let msgs = extract_messages(&messages);
|
||||
Ok(serde_json::to_string(&msgs).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn get_updates(session: &Session) -> Result<String, String> {
|
||||
let mut mtp = make_mtp(session)?;
|
||||
|
||||
let request = tl::functions::updates::GetState {}.to_bytes();
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, true)?;
|
||||
let state = tl::enums::updates::State::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse State: {e}"))?;
|
||||
|
||||
let tl::enums::updates::State::State(s) = state;
|
||||
|
||||
let request = tl::functions::updates::GetDifference {
|
||||
pts: s.pts.saturating_sub(10),
|
||||
pts_limit: None,
|
||||
pts_total_limit: None,
|
||||
date: s.date,
|
||||
qts: s.qts,
|
||||
qts_limit: None,
|
||||
}
|
||||
.to_bytes();
|
||||
|
||||
let resp_bytes = rpc_call(&mut mtp, session, request, false)?;
|
||||
let diff = tl::enums::updates::Difference::from_bytes(&resp_bytes)
|
||||
.map_err(|e| format!("parse Difference: {e}"))?;
|
||||
|
||||
let updates = extract_updates_from_diff(&diff);
|
||||
Ok(serde_json::to_string(&updates).unwrap_or_default())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a successful auth response with user info.
|
||||
fn format_user_auth(user: &tl::enums::User) -> String {
|
||||
match user {
|
||||
tl::enums::User::User(u) => serde_json::to_string(&AuthResult {
|
||||
status: "logged_in".into(),
|
||||
user: Some(UserInfo {
|
||||
id: u.id,
|
||||
first_name: u.first_name.clone().unwrap_or_default(),
|
||||
last_name: u.last_name.clone(),
|
||||
username: u.username.clone(),
|
||||
phone_number: u.phone.clone(),
|
||||
}),
|
||||
message: None,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
tl::enums::User::Empty(e) => serde_json::to_string(&AuthResult {
|
||||
status: "logged_in".into(),
|
||||
user: Some(UserInfo {
|
||||
id: e.id,
|
||||
first_name: "Unknown".into(),
|
||||
last_name: None,
|
||||
username: None,
|
||||
phone_number: None,
|
||||
}),
|
||||
message: None,
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a chat_id to an InputPeer. Negative IDs are channels/supergroups.
|
||||
fn resolve_peer(chat_id: i64) -> tl::enums::InputPeer {
|
||||
if chat_id > 0 {
|
||||
tl::enums::InputPeer::User(tl::types::InputPeerUser {
|
||||
user_id: chat_id,
|
||||
access_hash: 0,
|
||||
})
|
||||
} else {
|
||||
let abs_id = chat_id.unsigned_abs() as i64;
|
||||
if abs_id > 1_000_000_000_000 {
|
||||
// Channel/supergroup: strip -100 prefix
|
||||
let channel_id = abs_id - 1_000_000_000_000;
|
||||
tl::enums::InputPeer::Channel(tl::types::InputPeerChannel {
|
||||
channel_id,
|
||||
access_hash: 0,
|
||||
})
|
||||
} else {
|
||||
tl::enums::InputPeer::Chat(tl::types::InputPeerChat { chat_id: abs_id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_chats_from_dialogs(dialogs: &tl::enums::messages::Dialogs) -> Vec<ChatInfo> {
|
||||
let chats = match dialogs {
|
||||
tl::enums::messages::Dialogs::Dialogs(d) => &d.chats,
|
||||
tl::enums::messages::Dialogs::Slice(d) => &d.chats,
|
||||
tl::enums::messages::Dialogs::NotModified(_) => return vec![],
|
||||
};
|
||||
|
||||
chats.iter().filter_map(chat_to_info).collect()
|
||||
}
|
||||
|
||||
fn chat_to_info(chat: &tl::enums::Chat) -> Option<ChatInfo> {
|
||||
match chat {
|
||||
tl::enums::Chat::Chat(c) => Some(ChatInfo {
|
||||
id: -(c.id),
|
||||
chat_type: "group".into(),
|
||||
title: Some(c.title.clone()),
|
||||
username: None,
|
||||
}),
|
||||
tl::enums::Chat::Channel(c) => Some(ChatInfo {
|
||||
id: -(1_000_000_000_000 + c.id),
|
||||
chat_type: if c.megagroup { "supergroup" } else { "channel" }.into(),
|
||||
title: Some(c.title.clone()),
|
||||
username: c.username.clone(),
|
||||
}),
|
||||
tl::enums::Chat::Forbidden(c) => Some(ChatInfo {
|
||||
id: -(c.id),
|
||||
chat_type: "group".into(),
|
||||
title: Some(c.title.clone()),
|
||||
username: None,
|
||||
}),
|
||||
tl::enums::Chat::ChannelForbidden(c) => Some(ChatInfo {
|
||||
id: -(1_000_000_000_000 + c.id),
|
||||
chat_type: "channel".into(),
|
||||
title: Some(c.title.clone()),
|
||||
username: None,
|
||||
}),
|
||||
tl::enums::Chat::Empty(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_messages(msgs: &tl::enums::messages::Messages) -> Vec<MessageInfo> {
|
||||
let messages = match msgs {
|
||||
tl::enums::messages::Messages::Messages(m) => &m.messages,
|
||||
tl::enums::messages::Messages::Slice(m) => &m.messages,
|
||||
tl::enums::messages::Messages::ChannelMessages(m) => &m.messages,
|
||||
tl::enums::messages::Messages::NotModified(_) => return vec![],
|
||||
};
|
||||
|
||||
messages.iter().filter_map(message_to_info).collect()
|
||||
}
|
||||
|
||||
fn message_to_info(msg: &tl::enums::Message) -> Option<MessageInfo> {
|
||||
match msg {
|
||||
tl::enums::Message::Message(m) => Some(MessageInfo {
|
||||
message_id: m.id,
|
||||
date: m.date,
|
||||
from_user_id: m.from_id.as_ref().and_then(peer_id),
|
||||
text: Some(m.message.clone()),
|
||||
chat_id: Some(peer_id_value(&m.peer_id)),
|
||||
}),
|
||||
tl::enums::Message::Service(m) => Some(MessageInfo {
|
||||
message_id: m.id,
|
||||
date: m.date,
|
||||
from_user_id: m.from_id.as_ref().and_then(peer_id),
|
||||
text: Some("[service message]".into()),
|
||||
chat_id: Some(peer_id_value(&m.peer_id)),
|
||||
}),
|
||||
tl::enums::Message::Empty(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn peer_id(peer: &tl::enums::Peer) -> Option<i64> {
|
||||
Some(peer_id_value(peer))
|
||||
}
|
||||
|
||||
fn peer_id_value(peer: &tl::enums::Peer) -> i64 {
|
||||
match peer {
|
||||
tl::enums::Peer::User(p) => p.user_id,
|
||||
tl::enums::Peer::Chat(p) => -(p.chat_id),
|
||||
tl::enums::Peer::Channel(p) => -(1_000_000_000_000 + p.channel_id),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_updates_from_diff(diff: &tl::enums::updates::Difference) -> Vec<UpdateInfo> {
|
||||
match diff {
|
||||
tl::enums::updates::Difference::Difference(d) => extract_update_list(&d.new_messages),
|
||||
tl::enums::updates::Difference::Slice(d) => extract_update_list(&d.new_messages),
|
||||
tl::enums::updates::Difference::Empty(_) => vec![],
|
||||
tl::enums::updates::Difference::TooLong(_) => {
|
||||
vec![UpdateInfo {
|
||||
update_type: "too_long".into(),
|
||||
message: None,
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_update_list(messages: &[tl::enums::Message]) -> Vec<UpdateInfo> {
|
||||
messages
|
||||
.iter()
|
||||
.filter_map(|m| {
|
||||
message_to_info(m).map(|info| UpdateInfo {
|
||||
update_type: "new_message".into(),
|
||||
message: Some(info),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use grammers_mtproto::authentication;
|
||||
use grammers_tl_types::{self as tl, Deserializable};
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::transport;
|
||||
|
||||
/// Perform the full DH auth key exchange with a Telegram DC.
|
||||
///
|
||||
/// This drives the Sans-IO `grammers_mtproto::authentication` module over
|
||||
/// HTTP transport. Four round trips:
|
||||
///
|
||||
/// 1. step1 -> ReqPqMulti -> server returns ResPq
|
||||
/// 2. step2 -> ReqDhParams -> server returns ServerDhParams
|
||||
/// 3. step3 -> SetClientDhParams -> server returns DhGen answer
|
||||
/// 4. create_key -> produces auth_key, salt, time_offset
|
||||
pub fn generate_auth_key(session: &mut Session) -> Result<(), String> {
|
||||
let dc_id = session.dc_id;
|
||||
|
||||
// Step 1: generate nonce, send ReqPqMulti
|
||||
let (request, step1_data) =
|
||||
authentication::step1().map_err(|e| format!("auth step1 failed: {e}"))?;
|
||||
|
||||
let response_bytes = transport::post_plain(dc_id, &request)?;
|
||||
let res_pq = tl::enums::ResPq::from_bytes(&response_bytes)
|
||||
.map_err(|e| format!("failed to parse ResPq: {e}"))?;
|
||||
|
||||
// Step 2: factorize PQ, RSA encrypt, send ReqDhParams
|
||||
let (request, step2_data) =
|
||||
authentication::step2(step1_data, res_pq).map_err(|e| format!("auth step2 failed: {e}"))?;
|
||||
|
||||
let response_bytes = transport::post_plain(dc_id, &request)?;
|
||||
let server_dh = tl::enums::ServerDhParams::from_bytes(&response_bytes)
|
||||
.map_err(|e| format!("failed to parse ServerDhParams: {e}"))?;
|
||||
|
||||
// Step 3: compute DH g_b, send SetClientDhParams
|
||||
let (request, step3_data) = authentication::step3(step2_data, server_dh)
|
||||
.map_err(|e| format!("auth step3 failed: {e}"))?;
|
||||
|
||||
let response_bytes = transport::post_plain(dc_id, &request)?;
|
||||
let dh_answer = tl::enums::SetClientDhParamsAnswer::from_bytes(&response_bytes)
|
||||
.map_err(|e| format!("failed to parse DhGenAnswer: {e}"))?;
|
||||
|
||||
// Final: derive auth key from shared secret
|
||||
let finished = authentication::create_key(step3_data, dh_answer)
|
||||
.map_err(|e| format!("auth create_key failed: {e}"))?;
|
||||
|
||||
session.set_auth_key(&finished.auth_key);
|
||||
session.first_salt = finished.first_salt;
|
||||
session.time_offset = finished.time_offset;
|
||||
session.initialized = true;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
//! Telegram User-Mode WASM Tool for IronClaw.
|
||||
//!
|
||||
//! Provides Telegram integration operating from the **user's personal account**,
|
||||
//! not a bot. This tool sends encrypted MTProto messages directly to Telegram's
|
||||
//! data centers via HTTPS POST, using the grammers crate for the Sans-IO
|
||||
//! protocol implementation.
|
||||
//!
|
||||
//! # Architecture
|
||||
//!
|
||||
//! ```text
|
||||
//! WASM Tool ──MTProto/HTTPS──► Telegram DC (*.web.telegram.org/apiw)
|
||||
//! ```
|
||||
//!
|
||||
//! No Docker container, no middleware. The tool performs the DH key exchange,
|
||||
//! encrypts requests with the auth key, and POSTs raw ciphertext to Telegram's
|
||||
//! web transport endpoint.
|
||||
//!
|
||||
//! # Session Persistence
|
||||
//!
|
||||
//! Session state (auth key, salt, DC, login status) is stored in the workspace
|
||||
//! at `telegram/session.json`. The agent should save updated session data after
|
||||
//! auth actions using `memory_write`.
|
||||
//!
|
||||
//! # Prerequisites
|
||||
//!
|
||||
//! 1. Get Telegram API credentials from https://my.telegram.org/apps
|
||||
//! 2. Store them: `ironclaw secret set telegram_api_id <id>`
|
||||
//! `ironclaw secret set telegram_api_hash <hash>`
|
||||
//! 3. Use the `login` action with your phone number
|
||||
//!
|
||||
//! # Authentication Flow
|
||||
//!
|
||||
//! 1. Call `login` with your phone number
|
||||
//! - Generates an auth key (DH exchange with Telegram DC)
|
||||
//! - Sends verification code to your phone
|
||||
//! - Returns session data and phone_code_hash
|
||||
//! 2. Call `submit_auth_code` with the verification code
|
||||
//! 3. Call `submit_2fa_password` if you have 2FA enabled
|
||||
//! 4. After each auth step, save the returned `session` JSON to
|
||||
//! `telegram/session.json` via `memory_write`
|
||||
//!
|
||||
//! # Privacy
|
||||
//!
|
||||
//! - `get_messages` does NOT mark messages as read
|
||||
//! - Messages are read via `messages.getHistory`, not `getUpdates`
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
mod session;
|
||||
mod transport;
|
||||
mod types;
|
||||
|
||||
use session::Session;
|
||||
use types::TelegramAction;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
struct TelegramTool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for TelegramTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
SCHEMA.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"Telegram user-mode integration for reading and sending messages from the user's \
|
||||
personal account. Supports contacts, chat history, message search, sending, \
|
||||
forwarding, and deletion. Communicates directly with Telegram's servers via \
|
||||
encrypted MTProto over HTTPS (no Docker/TDLight needed). Does NOT mark messages \
|
||||
as read when reading history. Use the 'login' action to authenticate with your \
|
||||
phone number. Session state is persisted in the workspace at telegram/session.json."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
let action: TelegramAction =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {e}"))?;
|
||||
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Info,
|
||||
&format!("Executing Telegram action: {action:?}"),
|
||||
);
|
||||
|
||||
match action {
|
||||
TelegramAction::Login { phone_number } => execute_login(&phone_number),
|
||||
TelegramAction::SubmitAuthCode { code } => execute_submit_code(&code),
|
||||
TelegramAction::Submit2faPassword { password } => execute_submit_2fa(&password),
|
||||
TelegramAction::GetMe => with_session(api::get_me),
|
||||
TelegramAction::GetContacts => with_session(api::get_contacts),
|
||||
TelegramAction::GetChats { limit } => with_session(|s| api::get_chats(s, limit)),
|
||||
TelegramAction::GetMessages {
|
||||
chat_id,
|
||||
limit,
|
||||
from_message_id,
|
||||
} => with_session(|s| api::get_messages(s, chat_id, limit, from_message_id)),
|
||||
TelegramAction::SendMessage { chat_id, text } => {
|
||||
with_session(|s| api::send_message(s, chat_id, &text))
|
||||
}
|
||||
TelegramAction::ForwardMessage {
|
||||
from_chat_id,
|
||||
to_chat_id,
|
||||
message_ids,
|
||||
} => with_session(|s| api::forward_message(s, from_chat_id, to_chat_id, message_ids)),
|
||||
TelegramAction::DeleteMessage {
|
||||
message_ids,
|
||||
revoke,
|
||||
} => with_session(|s| api::delete_messages(s, message_ids, revoke)),
|
||||
TelegramAction::SearchMessages {
|
||||
query,
|
||||
chat_id,
|
||||
limit,
|
||||
} => with_session(|s| api::search_messages(s, &query, chat_id, limit)),
|
||||
TelegramAction::GetUpdates => with_session(api::get_updates),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load session from workspace, verify it's initialized and logged in, then run the action.
|
||||
fn with_session(f: impl FnOnce(&Session) -> Result<String, String>) -> Result<String, String> {
|
||||
let session = session::load_session().ok_or(
|
||||
"No session found. Use the 'login' action first, then save the returned session \
|
||||
to telegram/session.json via memory_write."
|
||||
.to_string(),
|
||||
)?;
|
||||
|
||||
if !session.initialized {
|
||||
return Err("Session exists but auth key not generated. Run 'login' again.".into());
|
||||
}
|
||||
if !session.logged_in {
|
||||
return Err("Session exists but not logged in. Complete the login flow \
|
||||
(submit_auth_code / submit_2fa_password)."
|
||||
.into());
|
||||
}
|
||||
|
||||
f(&session)
|
||||
}
|
||||
|
||||
/// Login flow: create session, generate auth key, send verification code.
|
||||
fn execute_login(phone_number: &str) -> Result<String, String> {
|
||||
let api_id = get_api_id()?;
|
||||
let api_hash = get_api_hash()?;
|
||||
|
||||
// Default to DC2 (Venus) as it's commonly assigned to new sessions
|
||||
let dc_id = 2u8;
|
||||
|
||||
let mut session = Session::new(api_id, api_hash, dc_id);
|
||||
session.phone_number = Some(phone_number.to_string());
|
||||
|
||||
// Step 1: DH auth key exchange
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Info,
|
||||
"Starting DH auth key exchange with Telegram DC...",
|
||||
);
|
||||
auth::generate_auth_key(&mut session)?;
|
||||
|
||||
// Step 2: send verification code
|
||||
near::agent::host::log(
|
||||
near::agent::host::LogLevel::Info,
|
||||
"Auth key generated. Sending verification code...",
|
||||
);
|
||||
let result = api::send_code(&mut session)?;
|
||||
|
||||
// Return session + result so agent can persist it
|
||||
let session_json = session::session_to_json(&session)?;
|
||||
Ok(format!(
|
||||
"{{\"result\":{result},\"session\":{session_json},\"instructions\":\
|
||||
\"Save the 'session' object to telegram/session.json using memory_write.\"}}"
|
||||
))
|
||||
}
|
||||
|
||||
/// Submit auth code, return updated session.
|
||||
fn execute_submit_code(code: &str) -> Result<String, String> {
|
||||
let mut session =
|
||||
session::load_session().ok_or("No session found. Use 'login' first.".to_string())?;
|
||||
|
||||
let result = api::sign_in(&mut session, code)?;
|
||||
let session_json = session::session_to_json(&session)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"result\":{result},\"session\":{session_json},\"instructions\":\
|
||||
\"Save the 'session' object to telegram/session.json using memory_write.\"}}"
|
||||
))
|
||||
}
|
||||
|
||||
/// Submit 2FA password, return updated session.
|
||||
fn execute_submit_2fa(password: &str) -> Result<String, String> {
|
||||
let mut session =
|
||||
session::load_session().ok_or("No session found. Use 'login' first.".to_string())?;
|
||||
|
||||
let result = api::check_password(&mut session, password)?;
|
||||
let session_json = session::session_to_json(&session)?;
|
||||
|
||||
Ok(format!(
|
||||
"{{\"result\":{result},\"session\":{session_json},\"instructions\":\
|
||||
\"Save the 'session' object to telegram/session.json using memory_write.\"}}"
|
||||
))
|
||||
}
|
||||
|
||||
/// Read api_id from params or check secret existence.
|
||||
fn get_api_id() -> Result<i32, String> {
|
||||
// The secret store holds the value but WASM can't read it directly.
|
||||
// The api_id is injected via env or must be in capabilities.
|
||||
// For now, read from workspace config if available.
|
||||
if let Some(val) = near::agent::host::workspace_read("telegram/api_id") {
|
||||
return val
|
||||
.trim()
|
||||
.parse::<i32>()
|
||||
.map_err(|e| format!("invalid api_id in workspace: {e}"));
|
||||
}
|
||||
Err(
|
||||
"Telegram API ID not found. Store it in workspace at telegram/api_id \
|
||||
(just the numeric value) using memory_write."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn get_api_hash() -> Result<String, String> {
|
||||
if let Some(val) = near::agent::host::workspace_read("telegram/api_hash") {
|
||||
let trimmed = val.trim().to_string();
|
||||
if trimmed.is_empty() {
|
||||
return Err("telegram/api_hash is empty".into());
|
||||
}
|
||||
return Ok(trimmed);
|
||||
}
|
||||
Err(
|
||||
"Telegram API hash not found. Store it in workspace at telegram/api_hash \
|
||||
using memory_write."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
const SCHEMA: &str = r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "login" },
|
||||
"phone_number": {
|
||||
"type": "string",
|
||||
"description": "Phone number in international format (e.g., '+1234567890')"
|
||||
}
|
||||
},
|
||||
"required": ["action", "phone_number"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "submit_auth_code" },
|
||||
"code": {
|
||||
"type": "string",
|
||||
"description": "Verification code received via SMS or Telegram"
|
||||
}
|
||||
},
|
||||
"required": ["action", "code"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "submit_2fa_password" },
|
||||
"password": {
|
||||
"type": "string",
|
||||
"description": "Two-factor authentication password"
|
||||
}
|
||||
},
|
||||
"required": ["action", "password"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_me" }
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_contacts" }
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_chats" },
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of chats to return (default: 20)",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_messages" },
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Chat ID (negative for groups/channels)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages (default: 20)",
|
||||
"default": 20
|
||||
},
|
||||
"from_message_id": {
|
||||
"type": "integer",
|
||||
"description": "Start from this message ID for pagination"
|
||||
}
|
||||
},
|
||||
"required": ["action", "chat_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "send_message" },
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Chat ID to send the message to"
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Message text"
|
||||
}
|
||||
},
|
||||
"required": ["action", "chat_id", "text"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "forward_message" },
|
||||
"from_chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Source chat ID"
|
||||
},
|
||||
"to_chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Destination chat ID"
|
||||
},
|
||||
"message_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Message IDs to forward"
|
||||
}
|
||||
},
|
||||
"required": ["action", "from_chat_id", "to_chat_id", "message_ids"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "delete_message" },
|
||||
"message_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "integer" },
|
||||
"description": "Message IDs to delete"
|
||||
},
|
||||
"revoke": {
|
||||
"type": "boolean",
|
||||
"description": "Also delete for other participants (default: false)",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_ids"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "search_messages" },
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query"
|
||||
},
|
||||
"chat_id": {
|
||||
"type": "integer",
|
||||
"description": "Chat ID to search within (omit for global search)"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results (default: 20)",
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
"required": ["action", "query"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_updates" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
export!(TelegramTool);
|
||||
@@ -0,0 +1,142 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Persistent session state, stored as base64 in the workspace at telegram/session.json.
|
||||
///
|
||||
/// Contains everything needed to resume an encrypted MTProto session between
|
||||
/// WASM invocations: auth key, server salt, DC identifier, API credentials,
|
||||
/// and transient login state.
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
pub struct Session {
|
||||
/// 256-byte auth key from the DH exchange, hex-encoded for JSON safety.
|
||||
pub auth_key_hex: String,
|
||||
/// First salt from DH exchange (or most recent salt from server).
|
||||
pub first_salt: i64,
|
||||
/// Time offset from server, in seconds.
|
||||
pub time_offset: i32,
|
||||
/// Telegram data center ID (1-5).
|
||||
pub dc_id: u8,
|
||||
/// Telegram API ID from my.telegram.org.
|
||||
pub api_id: i32,
|
||||
/// Telegram API hash from my.telegram.org.
|
||||
pub api_hash: String,
|
||||
/// Whether this session has completed auth key generation.
|
||||
pub initialized: bool,
|
||||
/// Whether a user is logged in.
|
||||
pub logged_in: bool,
|
||||
/// Transient: phone_code_hash from auth.sendCode, needed for auth.signIn.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone_code_hash: Option<String>,
|
||||
/// Transient: phone number used during login.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone_number: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(api_id: i32, api_hash: String, dc_id: u8) -> Self {
|
||||
Self {
|
||||
auth_key_hex: String::new(),
|
||||
first_salt: 0,
|
||||
time_offset: 0,
|
||||
dc_id,
|
||||
api_id,
|
||||
api_hash,
|
||||
initialized: false,
|
||||
logged_in: false,
|
||||
phone_code_hash: None,
|
||||
phone_number: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn auth_key_bytes(&self) -> Result<[u8; 256], String> {
|
||||
let bytes = hex_decode(&self.auth_key_hex)
|
||||
.map_err(|e| format!("corrupt auth_key_hex in session: {e}"))?;
|
||||
if bytes.len() != 256 {
|
||||
return Err(format!(
|
||||
"auth_key_hex decoded to {} bytes, expected 256",
|
||||
bytes.len()
|
||||
));
|
||||
}
|
||||
let mut key = [0u8; 256];
|
||||
key.copy_from_slice(&bytes);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
pub fn set_auth_key(&mut self, key: &[u8; 256]) {
|
||||
self.auth_key_hex = hex_encode(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load session from workspace (returns None if not found or unparseable).
|
||||
pub fn load_session() -> Option<Session> {
|
||||
let data = crate::near::agent::host::workspace_read("telegram/session.json")?;
|
||||
serde_json::from_str(&data).ok()
|
||||
}
|
||||
|
||||
/// Serialize session to JSON for the agent to store via memory_write.
|
||||
pub fn session_to_json(session: &Session) -> Result<String, String> {
|
||||
serde_json::to_string_pretty(session).map_err(|e| format!("session serialize failed: {e}"))
|
||||
}
|
||||
|
||||
// Minimal hex encode/decode (no extra dep needed).
|
||||
|
||||
fn hex_encode(bytes: &[u8]) -> String {
|
||||
const HEX: &[u8; 16] = b"0123456789abcdef";
|
||||
let mut out = String::with_capacity(bytes.len() * 2);
|
||||
for &b in bytes {
|
||||
out.push(HEX[(b >> 4) as usize] as char);
|
||||
out.push(HEX[(b & 0xf) as usize] as char);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn hex_decode(s: &str) -> Result<Vec<u8>, String> {
|
||||
if s.len() % 2 != 0 {
|
||||
return Err("odd-length hex string".into());
|
||||
}
|
||||
let mut out = Vec::with_capacity(s.len() / 2);
|
||||
let bytes = s.as_bytes();
|
||||
for chunk in bytes.chunks(2) {
|
||||
let hi = hex_val(chunk[0])?;
|
||||
let lo = hex_val(chunk[1])?;
|
||||
out.push((hi << 4) | lo);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Result<u8, String> {
|
||||
match b {
|
||||
b'0'..=b'9' => Ok(b - b'0'),
|
||||
b'a'..=b'f' => Ok(b - b'a' + 10),
|
||||
b'A'..=b'F' => Ok(b - b'A' + 10),
|
||||
_ => Err(format!("invalid hex char: {b}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hex_roundtrip() {
|
||||
let data = [0u8, 1, 15, 16, 255, 128, 64];
|
||||
let encoded = hex_encode(&data);
|
||||
assert_eq!(encoded, "00010f10ff8040");
|
||||
let decoded = hex_decode(&encoded).unwrap();
|
||||
assert_eq!(decoded, data);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_serialization() {
|
||||
let mut session = Session::new(12345, "abcdef".into(), 2);
|
||||
let key = [42u8; 256];
|
||||
session.set_auth_key(&key);
|
||||
session.initialized = true;
|
||||
|
||||
let json = session_to_json(&session).unwrap();
|
||||
let restored: Session = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(restored.auth_key_bytes().unwrap(), key);
|
||||
assert_eq!(restored.api_id, 12345);
|
||||
assert_eq!(restored.dc_id, 2);
|
||||
assert!(restored.initialized);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
use grammers_crypto::DequeBuffer;
|
||||
use grammers_mtproto::mtp::{Deserialization, Encrypted, Mtp, Plain};
|
||||
use grammers_tl_types::Serializable;
|
||||
|
||||
use crate::near::agent::host;
|
||||
|
||||
/// DC names indexed by dc_id (1-based). DC1=pluto, DC2=venus, etc.
|
||||
const DC_NAMES: &[&str] = &["", "pluto", "venus", "aurora", "vesta", "flora"];
|
||||
|
||||
/// Build the HTTPS URL for a Telegram data center's web transport endpoint.
|
||||
pub fn dc_url(dc_id: u8) -> Result<String, String> {
|
||||
let idx = dc_id as usize;
|
||||
if idx == 0 || idx >= DC_NAMES.len() {
|
||||
return Err(format!("invalid dc_id {dc_id}, must be 1-5"));
|
||||
}
|
||||
Ok(format!("https://{}.web.telegram.org/apiw", DC_NAMES[idx]))
|
||||
}
|
||||
|
||||
/// Send a plaintext (unencrypted) MTProto request via HTTP POST.
|
||||
///
|
||||
/// Used during auth key generation. The request is a TL-serializable type;
|
||||
/// the response bytes are returned raw for the caller to deserialize.
|
||||
pub fn post_plain<R: Serializable>(dc_id: u8, request: &R) -> Result<Vec<u8>, String> {
|
||||
let url = dc_url(dc_id)?;
|
||||
let mut plain = Plain::new();
|
||||
let mut buffer = DequeBuffer::with_capacity(0, 0);
|
||||
|
||||
let request_bytes = request.to_bytes();
|
||||
plain
|
||||
.push(&mut buffer, &request_bytes)
|
||||
.ok_or("plain push returned None")?;
|
||||
plain.finalize(&mut buffer);
|
||||
|
||||
let body: Vec<u8> = buffer[..].to_vec();
|
||||
let response = http_post_binary(&url, &body)?;
|
||||
|
||||
let results = plain
|
||||
.deserialize(&response)
|
||||
.map_err(|e| format!("plain deserialize: {e}"))?;
|
||||
|
||||
for result in results {
|
||||
if let Deserialization::RpcResult(rpc) = result {
|
||||
return Ok(rpc.body);
|
||||
}
|
||||
}
|
||||
Err("no RPC result in plain response".into())
|
||||
}
|
||||
|
||||
/// Send an encrypted MTProto RPC request via HTTP POST.
|
||||
///
|
||||
/// Pushes a serialized TL request into the Encrypted MTP, finalizes (encrypts),
|
||||
/// POSTs the ciphertext, then deserializes the response.
|
||||
///
|
||||
/// Returns the first RPC result body for the caller to deserialize as the
|
||||
/// expected response type.
|
||||
pub fn post_encrypted(
|
||||
mtp: &mut Encrypted,
|
||||
dc_id: u8,
|
||||
request_bytes: &[u8],
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let url = dc_url(dc_id)?;
|
||||
let mut buffer = DequeBuffer::with_capacity(0, 0);
|
||||
|
||||
mtp.push(&mut buffer, request_bytes)
|
||||
.ok_or("encrypted push returned None")?;
|
||||
mtp.finalize(&mut buffer);
|
||||
|
||||
let body: Vec<u8> = buffer[..].to_vec();
|
||||
let response = http_post_binary(&url, &body)?;
|
||||
|
||||
let results = mtp
|
||||
.deserialize(&response)
|
||||
.map_err(|e| format!("encrypted deserialize: {e}"))?;
|
||||
|
||||
for result in results {
|
||||
match result {
|
||||
Deserialization::RpcResult(rpc) => return Ok(rpc.body),
|
||||
Deserialization::RpcError(err) => {
|
||||
return Err(format!(
|
||||
"RPC error {}: {}",
|
||||
err.error.error_code, err.error.error_message
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err("no RPC result in encrypted response".into())
|
||||
}
|
||||
|
||||
/// HTTP POST with raw binary body via the WASM host's http-request capability.
|
||||
fn http_post_binary(url: &str, body: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let resp = host::http_request("POST", url, "{}", Some(body))?;
|
||||
|
||||
if resp.status < 200 || resp.status >= 300 {
|
||||
let body_text = String::from_utf8_lossy(&resp.body);
|
||||
return Err(format!(
|
||||
"HTTP {} from {}: {}",
|
||||
resp.status,
|
||||
url,
|
||||
truncate(&body_text, 200)
|
||||
));
|
||||
}
|
||||
|
||||
Ok(resp.body)
|
||||
}
|
||||
|
||||
fn truncate(s: &str, max: usize) -> &str {
|
||||
if s.len() <= max {
|
||||
s
|
||||
} else {
|
||||
&s[..max]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dc_url_valid() {
|
||||
assert_eq!(dc_url(1).unwrap(), "https://pluto.web.telegram.org/apiw");
|
||||
assert_eq!(dc_url(2).unwrap(), "https://venus.web.telegram.org/apiw");
|
||||
assert_eq!(dc_url(5).unwrap(), "https://flora.web.telegram.org/apiw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dc_url_invalid() {
|
||||
assert!(dc_url(0).is_err());
|
||||
assert!(dc_url(6).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
//! Types for the Telegram user-mode tool (MTProto direct).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input parameters for the Telegram tool.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum TelegramAction {
|
||||
/// Start login: generate auth key + send verification code.
|
||||
Login {
|
||||
/// Phone number in international format (e.g., "+1234567890").
|
||||
phone_number: String,
|
||||
},
|
||||
|
||||
/// Submit the verification code received after login.
|
||||
SubmitAuthCode {
|
||||
/// The verification code received via SMS or Telegram.
|
||||
code: String,
|
||||
},
|
||||
|
||||
/// Submit 2FA password if the account has two-factor auth enabled.
|
||||
Submit2faPassword {
|
||||
/// The two-factor authentication password.
|
||||
password: String,
|
||||
},
|
||||
|
||||
/// Get the authenticated user's profile info.
|
||||
GetMe,
|
||||
|
||||
/// Get the user's contact list.
|
||||
GetContacts,
|
||||
|
||||
/// List the user's recent chats/conversations.
|
||||
GetChats {
|
||||
/// Maximum number of chats to return (default: 20).
|
||||
#[serde(default = "default_chat_limit")]
|
||||
limit: i32,
|
||||
},
|
||||
|
||||
/// Read message history from a chat. Does NOT mark messages as read.
|
||||
GetMessages {
|
||||
/// Chat ID (numeric, negative for groups/channels).
|
||||
chat_id: i64,
|
||||
/// Maximum number of messages to return (default: 20).
|
||||
#[serde(default = "default_message_limit")]
|
||||
limit: i32,
|
||||
/// Return messages starting from this message ID (for pagination).
|
||||
#[serde(default)]
|
||||
from_message_id: Option<i32>,
|
||||
},
|
||||
|
||||
/// Send a text message to a chat.
|
||||
SendMessage {
|
||||
/// Chat ID to send the message to.
|
||||
chat_id: i64,
|
||||
/// Message text.
|
||||
text: String,
|
||||
},
|
||||
|
||||
/// Forward messages from one chat to another.
|
||||
ForwardMessage {
|
||||
/// Source chat ID.
|
||||
from_chat_id: i64,
|
||||
/// Destination chat ID.
|
||||
to_chat_id: i64,
|
||||
/// Message IDs to forward.
|
||||
message_ids: Vec<i32>,
|
||||
},
|
||||
|
||||
/// Delete messages.
|
||||
DeleteMessage {
|
||||
/// Message IDs to delete.
|
||||
message_ids: Vec<i32>,
|
||||
/// Also delete for other participants (default: false).
|
||||
#[serde(default)]
|
||||
revoke: bool,
|
||||
},
|
||||
|
||||
/// Search for messages across chats or within a specific chat.
|
||||
SearchMessages {
|
||||
/// Query string to search for.
|
||||
query: String,
|
||||
/// Chat ID to search within (omit for global search).
|
||||
#[serde(default)]
|
||||
chat_id: Option<i64>,
|
||||
/// Maximum number of results (default: 20).
|
||||
#[serde(default = "default_message_limit")]
|
||||
limit: i32,
|
||||
},
|
||||
|
||||
/// Poll for new incoming updates.
|
||||
GetUpdates,
|
||||
}
|
||||
|
||||
fn default_chat_limit() -> i32 {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_message_limit() -> i32 {
|
||||
20
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Result from the login action (code_sent phase).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct LoginResult {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone_code_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// Result from auth code / 2FA / signIn.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthResult {
|
||||
pub status: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub user: Option<UserInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<String>,
|
||||
}
|
||||
|
||||
/// User profile info.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UserInfo {
|
||||
pub id: i64,
|
||||
pub first_name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub phone_number: Option<String>,
|
||||
}
|
||||
|
||||
/// Chat information.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ChatInfo {
|
||||
pub id: i64,
|
||||
#[serde(rename = "type")]
|
||||
pub chat_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub username: Option<String>,
|
||||
}
|
||||
|
||||
/// A message in a chat.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessageInfo {
|
||||
pub message_id: i32,
|
||||
pub date: i32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub from_user_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub chat_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Result from sending a message.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendResult {
|
||||
pub message_id: i32,
|
||||
pub date: i32,
|
||||
}
|
||||
|
||||
/// Result from forwarding messages.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ForwardResult {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// Result from deleting messages.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DeleteResult {
|
||||
pub ok: bool,
|
||||
}
|
||||
|
||||
/// An update from getDifference.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub update_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub message: Option<MessageInfo>,
|
||||
}
|
||||
Reference in New Issue
Block a user