Add Mini App artifact delivery for previewable files
Build Claw Telegram / build (push) Successful in 5m44s
Build Claw Telegram / cleanup (push) Successful in 1s

Previewable files (HTML, MD, code, JSON, etc.) now open in
the Telegram Mini App instead of being uploaded as raw files.
Images and binaries still use Telegram upload.

- Add is_previewable() classifier for ~40 text/code extensions
- Add artifact registry with TTL-based expiry
- Add /miniapp/api/artifacts/:turn_id/:file_id proxy endpoint
- Add /miniapp/view/:turn_id/:file_id viewer with auto-auth
- Route previewable artifacts to "View in Mini App" web_app button
- Extract fetch_generated_file() for raw byte + content-type access

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wylabb
2026-04-05 11:39:51 +02:00
parent e7eaf9ed93
commit c04d952b1b
3 changed files with 364 additions and 9 deletions
+171 -1
View File
@@ -49,6 +49,44 @@ struct PendingApprovalState {
message_id: i64,
}
#[derive(Clone, Debug)]
pub struct ArtifactMeta {
pub profile_id: String,
pub worker_base_url: String,
pub file_name: String,
pub media_type: Option<String>,
pub registered_at: u64,
}
fn artifact_registry() -> &'static Arc<Mutex<BTreeMap<(String, String), ArtifactMeta>>> {
static REGISTRY: std::sync::OnceLock<Arc<Mutex<BTreeMap<(String, String), ArtifactMeta>>>> =
std::sync::OnceLock::new();
REGISTRY.get_or_init(|| Arc::new(Mutex::new(BTreeMap::new())))
}
pub async fn register_artifact(
turn_id: &str,
file_id: &str,
meta: ArtifactMeta,
ttl_secs: u64,
) {
let mut registry = artifact_registry().lock().await;
let now = now_epoch_secs();
registry.retain(|_, m| now.saturating_sub(m.registered_at) < ttl_secs);
registry.insert((turn_id.to_string(), file_id.to_string()), meta);
}
pub async fn lookup_artifact(turn_id: &str, file_id: &str) -> Option<ArtifactMeta> {
let registry = artifact_registry().lock().await;
registry.get(&(turn_id.to_string(), file_id.to_string())).cloned()
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
impl TelegramGateway {
pub fn new(config: GatewayConfig) -> Result<Self, GatewayError> {
std::fs::create_dir_all(&config.state_root)?;
@@ -517,6 +555,8 @@ impl TelegramGateway {
let state_root = self.config.state_root.clone();
let auth_token = self.config.worker_auth_token.clone();
let max_upload_bytes = self.config.max_upload_bytes();
let miniapp_base_url = self.config.miniapp_public_base_url.clone();
let miniapp_ttl = self.config.miniapp_session_ttl_secs;
async move {
let client = match WorkerClient::new(&worker_base_url, &auth_token) {
Ok(client) => client,
@@ -748,8 +788,11 @@ impl TelegramGateway {
.await;
}
}
let miniapp_base = miniapp_base_url.as_deref();
for descriptor in generated_files {
if descriptor.size_bytes > max_upload_bytes {
if descriptor.size_bytes > max_upload_bytes
&& !(miniapp_base.is_some() && is_previewable(&descriptor))
{
let _ = api
.send_message(
chat_id,
@@ -780,6 +823,49 @@ impl TelegramGateway {
continue;
}
};
if let Some(base_url) = miniapp_base {
if is_previewable(&descriptor) {
register_artifact(
&turn_id,
&descriptor.file_id,
ArtifactMeta {
profile_id: profile_id.clone(),
worker_base_url: worker_base_url.clone(),
file_name: descriptor.file_name.clone(),
media_type: descriptor.media_type.clone(),
registered_at: now_epoch_secs(),
},
miniapp_ttl,
)
.await;
let view_url = format!(
"{}/miniapp/view/{}/{}",
base_url.trim_end_matches('/'),
turn_id,
descriptor.file_id,
);
let keyboard = crate::telegram_api::InlineKeyboardMarkup {
inline_keyboard: vec![vec![
crate::telegram_api::InlineKeyboardButton {
text: format!("View: {}", descriptor.file_name),
callback_data: None,
url: None,
web_app: Some(crate::telegram_api::WebAppInfo {
url: view_url,
}),
},
]],
};
let _ = api
.send_message(
chat_id,
&format!("Artifact: {}", descriptor.file_name),
Some(&keyboard),
)
.await;
continue;
}
}
let send_result = if descriptor.is_image || is_image_path(&downloaded) {
api.send_photo_path(
chat_id,
@@ -1947,6 +2033,29 @@ fn is_image_path(path: &Path) -> bool {
})
}
fn is_previewable(descriptor: &channel_gateway_core::GeneratedFileDescriptor) -> bool {
let extension = std::path::Path::new(&descriptor.file_name)
.extension()
.and_then(|value| value.to_str())
.map(|value| value.to_ascii_lowercase());
let extension = match extension {
Some(ref ext) => ext.as_str(),
None => return false,
};
matches!(
extension,
"html" | "htm" | "md" | "markdown"
| "json" | "csv" | "txt"
| "rs" | "py" | "js" | "ts" | "tsx" | "jsx"
| "toml" | "yaml" | "yml" | "xml" | "css"
| "sh" | "bash" | "zsh"
| "diff" | "patch" | "log"
| "sql" | "go" | "java" | "c" | "cpp" | "h" | "hpp"
| "rb" | "swift" | "kt" | "kts"
| "svelte" | "vue"
)
}
fn spawn_typing_loop(
api: TelegramApi,
chat_id: i64,
@@ -2141,4 +2250,65 @@ mod tests {
let _ = std::fs::remove_dir_all(&state_root);
}
#[test]
fn is_previewable_classifies_common_extensions() {
use channel_gateway_core::GeneratedFileDescriptor;
let make = |name: &str| GeneratedFileDescriptor {
file_id: "f1".into(),
file_name: name.into(),
media_type: None,
size_bytes: 100,
is_image: false,
};
assert!(super::is_previewable(&make("report.html")));
assert!(super::is_previewable(&make("notes.md")));
assert!(super::is_previewable(&make("data.json")));
assert!(super::is_previewable(&make("main.rs")));
assert!(super::is_previewable(&make("app.tsx")));
assert!(super::is_previewable(&make("config.yaml")));
assert!(super::is_previewable(&make("output.diff")));
assert!(super::is_previewable(&make("query.sql")));
assert!(super::is_previewable(&make("REPORT.HTML")));
assert!(!super::is_previewable(&make("photo.png")));
assert!(!super::is_previewable(&make("image.jpg")));
assert!(!super::is_previewable(&make("archive.zip")));
assert!(!super::is_previewable(&make("doc.pdf")));
assert!(!super::is_previewable(&make("noextension")));
}
#[tokio::test]
async fn artifact_registry_stores_and_expires() {
use super::{lookup_artifact, register_artifact, ArtifactMeta, now_epoch_secs};
let meta = ArtifactMeta {
profile_id: "test".into(),
worker_base_url: "http://localhost:8080".into(),
file_name: "test.html".into(),
media_type: Some("text/html".into()),
registered_at: now_epoch_secs(),
};
register_artifact("turn-1", "file-1", meta.clone(), 3600).await;
assert!(lookup_artifact("turn-1", "file-1").await.is_some());
assert!(lookup_artifact("turn-1", "file-999").await.is_none());
// Insert an already-expired entry, then trigger pruning with a new insert
let expired_meta = ArtifactMeta {
registered_at: 0,
..meta.clone()
};
register_artifact("turn-old", "file-old", expired_meta, 3600).await;
// The expired entry survives its own insert (pruning runs before insert).
// Trigger another insert to prune it.
let fresh_meta = ArtifactMeta {
registered_at: now_epoch_secs(),
..meta
};
register_artifact("turn-2", "file-2", fresh_meta, 3600).await;
assert!(lookup_artifact("turn-old", "file-old").await.is_none());
}
}
+163 -2
View File
@@ -4,9 +4,9 @@ use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use axum::extract::State;
use axum::extract::{Path as AxumPath, Query, State};
use axum::http::{header, HeaderMap, StatusCode};
use axum::response::Html;
use axum::response::{Html, IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use channel_gateway_core::ProfileRecord;
@@ -306,6 +306,11 @@ pub async fn serve(config: GatewayConfig, bind_addr: String) -> Result<(), MiniA
.route("/miniapp/api/agents", get(agents))
.route("/miniapp/api/mailbox", get(mailbox))
.route("/miniapp/api/approvals", get(approvals))
.route(
"/miniapp/api/artifacts/:turn_id/:file_id",
get(artifact_proxy),
)
.route("/miniapp/view/:turn_id/:file_id", get(artifact_viewer))
.with_state(Arc::new(state));
let listener = TcpListener::bind(&bind_addr).await?;
@@ -417,6 +422,162 @@ async fn approvals(
.map_err(status_from_worker_error)
}
#[derive(Debug, Deserialize)]
struct ArtifactViewerQuery {
#[serde(default)]
token: Option<String>,
}
async fn artifact_proxy(
State(state): State<Arc<MiniAppState>>,
headers: HeaderMap,
AxumPath((turn_id, file_id)): AxumPath<(String, String)>,
) -> Result<Response, StatusCode> {
let _session = authorize(&state, &headers).await?;
let meta = crate::gateway::lookup_artifact(&turn_id, &file_id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
let client = WorkerClient::new(&meta.worker_base_url, &state.config.worker_auth_token)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let payload = client
.fetch_generated_file(&turn_id, &file_id)
.await
.map_err(status_from_worker_error)?;
let content_type = payload
.content_type
.unwrap_or_else(|| "application/octet-stream".to_string());
Response::builder()
.header(header::CONTENT_TYPE, content_type)
.header(
header::CONTENT_DISPOSITION,
format!("inline; filename=\"{}\"", meta.file_name),
)
.body(axum::body::Body::from(payload.bytes))
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
}
async fn artifact_viewer(
State(state): State<Arc<MiniAppState>>,
AxumPath((turn_id, file_id)): AxumPath<(String, String)>,
Query(query): Query<ArtifactViewerQuery>,
) -> Result<Html<String>, StatusCode> {
let meta = crate::gateway::lookup_artifact(&turn_id, &file_id)
.await
.ok_or(StatusCode::NOT_FOUND)?;
let token_hint = query.token.as_deref().unwrap_or("");
let extension = std::path::Path::new(&meta.file_name)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let render_mode = match extension.as_str() {
"html" | "htm" => "html",
"md" | "markdown" => "markdown",
"json" => "json",
_ => "code",
};
let escaped_name = meta.file_name.replace('<', "&lt;").replace('>', "&gt;");
let page = format!(
r##"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>{escaped_name}</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<style>
:root {{ --bg: #1a1a2e; --panel: #16213e; --ink: #e0e0e0; --accent: #0f3460; --code-bg: #0d1117; }}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ background: var(--bg); color: var(--ink); font-family: -apple-system, system-ui, sans-serif; padding: 12px; }}
h1 {{ font-size: 14px; padding: 8px 0; color: #8899aa; }}
#content {{ background: var(--panel); border-radius: 8px; padding: 16px; overflow: auto; max-height: calc(100vh - 60px); }}
pre {{ background: var(--code-bg); padding: 12px; border-radius: 6px; overflow-x: auto; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }}
iframe {{ width: 100%; min-height: 80vh; border: 1px solid #333; border-radius: 6px; background: #fff; }}
.md h1,.md h2,.md h3 {{ margin: 12px 0 6px; }}
.md p {{ margin: 6px 0; }}
.md code {{ background: var(--code-bg); padding: 2px 6px; border-radius: 3px; font-size: 13px; }}
.md pre {{ margin: 8px 0; }}
.md ul,.md ol {{ margin: 6px 0; padding-left: 20px; }}
.error {{ color: #ff6b6b; padding: 16px; }}
</style>
</head>
<body>
<h1>{escaped_name}</h1>
<div id="content"><p>Loading...</p></div>
<script>
(async function() {{
const turnId = "{turn_id}";
const fileId = "{file_id}";
const renderMode = "{render_mode}";
let token = "{token_hint}";
const el = document.getElementById("content");
if (!token && window.Telegram && Telegram.WebApp && Telegram.WebApp.initData) {{
try {{
const res = await fetch("/miniapp/auth", {{
method: "POST",
headers: {{ "Content-Type": "application/json" }},
body: JSON.stringify({{ init_data: Telegram.WebApp.initData }}),
}});
if (res.ok) {{ token = (await res.json()).token; }}
}} catch(_) {{}}
}}
if (!token) {{
el.innerHTML = '<p class="error">Authentication required.</p>';
return;
}}
try {{
const res = await fetch("/miniapp/api/artifacts/" + turnId + "/" + fileId, {{
headers: {{ "Authorization": "Bearer " + token }},
}});
if (!res.ok) throw new Error("HTTP " + res.status);
const text = await res.text();
if (renderMode === "html") {{
el.innerHTML = '<iframe sandbox="allow-scripts allow-same-origin" srcdoc=""></iframe>';
el.querySelector("iframe").srcdoc = text;
}} else if (renderMode === "markdown") {{
el.classList.add("md");
el.innerHTML = simpleMarkdown(text);
}} else if (renderMode === "json") {{
try {{
el.innerHTML = "<pre>" + escapeHtml(JSON.stringify(JSON.parse(text), null, 2)) + "</pre>";
}} catch(_) {{
el.innerHTML = "<pre>" + escapeHtml(text) + "</pre>";
}}
}} else {{
el.innerHTML = "<pre>" + escapeHtml(text) + "</pre>";
}}
}} catch (err) {{
el.innerHTML = '<p class="error">Failed to load artifact: ' + escapeHtml(err.message) + '</p>';
}}
function escapeHtml(s) {{
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}}
function simpleMarkdown(md) {{
return md
.replace(/^### (.+)$/gm, "<h3>$1</h3>")
.replace(/^## (.+)$/gm, "<h2>$1</h2>")
.replace(/^# (.+)$/gm, "<h1>$1</h1>")
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/^- (.+)$/gm, "<li>$1</li>")
.replace(/(<li>.*<\/li>)/s, "<ul>$1</ul>")
.replace(/\n\n/g, "</p><p>")
.replace(/^/, "<p>").replace(/$/, "</p>");
}}
}})();
</script>
</body>
</html>"##
);
Ok(Html(page))
}
async fn authorize(
state: &MiniAppState,
headers: &HeaderMap,
+30 -6
View File
@@ -21,6 +21,11 @@ pub struct WorkerClient {
auth_token: String,
}
pub struct GeneratedFilePayload {
pub bytes: Vec<u8>,
pub content_type: Option<String>,
}
impl WorkerClient {
pub fn new(
base_url: impl Into<String>,
@@ -246,9 +251,24 @@ impl WorkerClient {
tokio::fs::create_dir_all(destination_dir)
.await
.map_err(WorkerClientError::Io)?;
let payload = self
.fetch_generated_file(turn_id, &descriptor.file_id)
.await?;
let destination = destination_dir.join(&descriptor.file_name);
tokio::fs::write(&destination, payload.bytes)
.await
.map_err(WorkerClientError::Io)?;
Ok(destination)
}
pub async fn fetch_generated_file(
&self,
turn_id: &str,
file_id: &str,
) -> Result<GeneratedFilePayload, WorkerClientError> {
let response = self
.client
.get(self.url(&format!("/v1/turns/{turn_id}/files/{}", descriptor.file_id)))
.get(self.url(&format!("/v1/turns/{turn_id}/files/{file_id}")))
.bearer_auth(&self.auth_token)
.send()
.await
@@ -256,12 +276,16 @@ impl WorkerClient {
let response = response
.error_for_status()
.map_err(WorkerClientError::Http)?;
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.map(str::to_string);
let bytes = response.bytes().await.map_err(WorkerClientError::Http)?;
let destination = destination_dir.join(&descriptor.file_name);
tokio::fs::write(&destination, bytes)
.await
.map_err(WorkerClientError::Io)?;
Ok(destination)
Ok(GeneratedFilePayload {
bytes: bytes.to_vec(),
content_type,
})
}
async fn get_json<T: for<'de> serde::Deserialize<'de>>(