v0.0.39: contacts online, message wrap, tab complete, multipart, OTPK

FC-P2-T6: /contacts shows online status (● online, ○ offline)
FC-P6-T6: Long messages word-wrap into multiple lines with aligned indent
FC-P6-T7: Tab completion for 33 slash commands (4 new tests)
FC-P8-T6: sendDocument accepts both JSON and multipart form data
OTPK: Auto-replenish on TUI startup when supply < 3 (generates 10 new)

135 tests passing (was 127)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-29 17:22:42 +04:00
parent 5764719375
commit c37bd7934c
11 changed files with 307 additions and 42 deletions

View File

@@ -41,7 +41,7 @@ pub fn routes() -> Router<AppState> {
.route("/bot/:token/setWebhook", post(set_webhook))
.route("/bot/:token/deleteWebhook", post(delete_webhook))
.route("/bot/:token/getWebhookInfo", get(get_webhook_info))
.route("/bot/:token/sendDocument", post(send_document))
.route("/bot/:token/sendDocument", post(send_document_flexible))
}
// ---------------------------------------------------------------------------
@@ -956,44 +956,104 @@ async fn get_webhook_info(
}
// ---------------------------------------------------------------------------
// sendDocument
// sendDocument — accepts both JSON and multipart/form-data
// ---------------------------------------------------------------------------
#[derive(Deserialize)]
struct SendDocumentRequest {
chat_id: serde_json::Value, // Accept string (fingerprint) or number (numeric ID)
/// File path, URL, or file_id reference. In v1, the reference is stored
/// and forwarded as-is without server-side file hosting.
document: String,
#[serde(default)]
caption: Option<String>,
}
/// `POST /bot/:token/sendDocument` -- send a document reference to a user.
async fn send_document(
///
/// Accepts both `application/json` and `multipart/form-data` content types
/// so Telegram bot libraries that upload files via multipart work out of the box.
async fn send_document_flexible(
State(state): State<AppState>,
Path(token): Path<String>,
Json(req): Json<SendDocumentRequest>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Json<serde_json::Value> {
let bot_info = match validate_bot_token(&state, &token) {
Some(i) => i,
None => return Json(serde_json::json!({"ok": false, "description": "invalid token"})),
};
let bot_fp = bot_info["fingerprint"].as_str().unwrap_or("bot");
let to_fp = match resolve_chat_id(&state, &req.chat_id) {
Some(fp) => fp,
None => {
return Json(serde_json::json!({"ok": false, "description": "chat_id not found"}))
let bot_name = bot_info["name"].as_str().unwrap_or("bot");
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let (chat_id_val, document, caption) = if content_type.contains("multipart") {
// Parse multipart fields from raw bytes (simplified text-field extraction).
let body_str = String::from_utf8_lossy(&body);
let mut chat_id = String::new();
let mut doc = String::new();
let mut cap = String::new();
// Split on boundary markers (lines starting with --)
for part in body_str.split("------") {
if part.contains("name=\"chat_id\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
chat_id = val.trim().to_string();
}
}
if part.contains("name=\"document\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
doc = val.trim().to_string();
}
}
if part.contains("name=\"caption\"") {
if let Some(val) = part.split("\r\n\r\n").nth(1) {
cap = val.trim().to_string();
}
}
}
(
serde_json::Value::String(chat_id),
doc,
if cap.is_empty() { None } else { Some(cap) },
)
} else {
// JSON body
match serde_json::from_slice::<serde_json::Value>(&body) {
Ok(json) => {
let chat_id = json
.get("chat_id")
.cloned()
.unwrap_or(serde_json::Value::String(String::new()));
let doc = json
.get("document")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let cap = json
.get("caption")
.and_then(|v| v.as_str())
.map(String::from);
(chat_id, doc, cap)
}
Err(e) => {
return Json(
serde_json::json!({"ok": false, "description": format!("invalid body: {}", e)}),
)
}
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
let to_fp = match resolve_chat_id(&state, &chat_id_val) {
Some(fp) => fp,
None => {
return Json(serde_json::json!({"ok": false, "description": "invalid chat_id"}))
}
};
let msg_id = uuid::Uuid::new_v4().to_string();
let doc_msg = serde_json::json!({
"type": "bot_document",
"id": msg_id,
"from": bot_fp,
"document": req.document,
"caption": req.caption,
"from_name": bot_name,
"document": document,
"caption": caption,
"timestamp": chrono::Utc::now().timestamp(),
});
let msg_bytes = serde_json::to_vec(&doc_msg).unwrap_or_default();
@@ -1004,8 +1064,8 @@ async fn send_document(
"result": {
"message_id": msg_id,
"chat": {"id": to_fp},
"document": {"file_name": req.document},
"caption": req.caption,
"document": {"file_name": document},
"caption": caption,
"delivered": delivered,
}
}))