Files
claw-code-parity/rust/crates/claw-telegram/src/unraid_template_manager.rs
T
Wylabb 9f4bf2c3ee
Build Claw Telegram / build (push) Successful in 4m54s
Build Claw Telegram / cleanup (push) Successful in 1s
Support Anthropic subscription OAuth
2026-04-05 02:23:55 +02:00

157 lines
10 KiB
Rust

use std::collections::BTreeSet;
use std::fmt::{Display, Formatter};
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use channel_gateway_core::{ManagedTemplateRecord, UnraidTemplateSpec};
#[derive(Debug, Clone)]
pub struct UnraidTemplateManager {
template_dir: PathBuf,
archive_dir: PathBuf,
template_prefix: String,
}
impl UnraidTemplateManager {
pub fn new(
template_dir: impl Into<PathBuf>,
archive_dir: impl Into<PathBuf>,
template_prefix: impl Into<String>,
) -> Self {
Self {
template_dir: template_dir.into(),
archive_dir: archive_dir.into(),
template_prefix: template_prefix.into(),
}
}
pub fn write_worker_template(
&self,
profile_id: &str,
template: &UnraidTemplateSpec,
) -> Result<ManagedTemplateRecord, TemplateError> {
std::fs::create_dir_all(&self.template_dir)?;
let path = self.template_path(profile_id);
let temp_path = path.with_extension("xml.tmp");
std::fs::write(&temp_path, template.render_xml())?;
std::fs::rename(temp_path, &path)?;
Ok(ManagedTemplateRecord {
profile_id: profile_id.to_string(),
file_path: path,
})
}
pub fn archive_removed_templates(
&self,
active_profile_ids: &BTreeSet<String>,
) -> Result<Vec<ManagedTemplateRecord>, TemplateError> {
std::fs::create_dir_all(&self.archive_dir)?;
let mut archived = Vec::new();
if !self.template_dir.exists() {
return Ok(archived);
}
for entry in std::fs::read_dir(&self.template_dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(file_name) = path.file_name().and_then(|value| value.to_str()) else {
continue;
};
if !file_name.starts_with(&self.template_prefix) || !file_name.ends_with(".xml") {
continue;
}
let profile_id = file_name
.trim_start_matches(&self.template_prefix)
.trim_end_matches(".xml")
.to_string();
if active_profile_ids.contains(&profile_id) {
continue;
}
let archived_path =
self.archive_dir
.join(format!("{}-{}", current_timestamp_secs(), file_name));
std::fs::rename(&path, &archived_path)?;
archived.push(ManagedTemplateRecord {
profile_id,
file_path: archived_path,
});
}
Ok(archived)
}
pub fn archive_template_for_profile(
&self,
profile_id: &str,
) -> Result<Option<ManagedTemplateRecord>, TemplateError> {
std::fs::create_dir_all(&self.archive_dir)?;
let path = self.template_path(profile_id);
if !path.exists() {
return Ok(None);
}
let archived_path = self.archive_dir.join(format!(
"{}-{}",
current_timestamp_secs(),
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("template.xml")
));
std::fs::rename(&path, &archived_path)?;
Ok(Some(ManagedTemplateRecord {
profile_id: profile_id.to_string(),
file_path: archived_path,
}))
}
pub fn template_path(&self, profile_id: &str) -> PathBuf {
self.template_dir
.join(format!("{}{}.xml", self.template_prefix, profile_id))
}
pub fn render_gateway_example(
&self,
destination: impl AsRef<Path>,
repository: &str,
) -> Result<(), TemplateError> {
let xml = format!(
"<?xml version=\"1.0\"?>\n<Container version=\"2\">\n <Name>claw-telegram-gateway</Name>\n <Repository>{repository}</Repository>\n <Registry></Registry>\n <Network>claw_gateway</Network>\n <MyIP></MyIP>\n <Shell>sh</Shell>\n <Privileged>false</Privileged>\n <Support>https://git.wylab.me/wylab/claw-code-parity</Support>\n <Project>claw-code-parity</Project>\n <Overview>Telegram gateway with Docker-socket worker management. Warning: mounting the Docker socket grants the container root-equivalent control over the Unraid host.</Overview>\n <Category>AI:Tools</Category>\n <WebUI></WebUI>\n <TemplateURL></TemplateURL>\n <Icon></Icon>\n <ExtraParams>--entrypoint claw-telegram</ExtraParams>\n <PostArgs>gateway serve</PostArgs>\n <CPUset></CPUset>\n <DateInstalled>{}</DateInstalled>\n <DonateText></DonateText>\n <DonateLink></DonateLink>\n <Requires></Requires>\n <Config Name=\"Docker Socket\" Target=\"/var/run/docker.sock\" Default=\"/var/run/docker.sock\" Mode=\"rw\" Description=\"Docker socket for worker orchestration; this grants root-equivalent control of the host\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/var/run/docker.sock</Config>\n <Config Name=\"Unraid Templates\" Target=\"/unraid/templates-user\" Default=\"/boot/config/plugins/dockerMan/templates-user\" Mode=\"rw\" Description=\"Unraid template directory\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/boot/config/plugins/dockerMan/templates-user</Config>\n <Config Name=\"Gateway AppData\" Target=\"/appdata\" Default=\"/mnt/user/appdata/claw-telegram-gateway\" Mode=\"rw\" Description=\"Gateway state and manifest directory\" Type=\"Path\" Display=\"always\" Required=\"true\" Mask=\"false\">/mnt/user/appdata/claw-telegram-gateway</Config>\n <Config Name=\"CLAW_GATEWAY_TELEGRAM_BOT_TOKEN\" Target=\"CLAW_GATEWAY_TELEGRAM_BOT_TOKEN\" Default=\"\" Mode=\"\" Description=\"Telegram bot token used by the gateway ingress\" Type=\"Variable\" Display=\"always\" Required=\"true\" Mask=\"true\"></Config>\n <Config Name=\"CLAW_WORKER_AUTH_TOKEN\" Target=\"CLAW_WORKER_AUTH_TOKEN\" Default=\"\" Mode=\"\" Description=\"Shared bearer token for worker API calls\" Type=\"Variable\" Display=\"always\" Required=\"true\" Mask=\"true\"></Config>\n <Config Name=\"CLAW_GATEWAY_WORKER_IMAGE\" Target=\"CLAW_GATEWAY_WORKER_IMAGE\" Default=\"{repository}\" Mode=\"\" Description=\"Worker image pulled by the gateway for per-profile containers\" Type=\"Variable\" Display=\"always\" Required=\"true\" Mask=\"false\">{repository}</Config>\n <Config Name=\"CLAW_GATEWAY_STATE_ROOT\" Target=\"CLAW_GATEWAY_STATE_ROOT\" Default=\"/appdata/state\" Mode=\"\" Description=\"Gateway runtime state root for offsets and downloaded files\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/appdata/state</Config>\n <Config Name=\"CLAW_GATEWAY_MANIFEST\" Target=\"CLAW_GATEWAY_MANIFEST\" Default=\"/appdata/profiles.json\" Mode=\"\" Description=\"Manifest path for channel identities and worker layout\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/appdata/profiles.json</Config>\n <Config Name=\"CLAW_GATEWAY_DOCKER_SOCKET\" Target=\"CLAW_GATEWAY_DOCKER_SOCKET\" Default=\"/var/run/docker.sock\" Mode=\"\" Description=\"Docker socket path inside the gateway container\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/var/run/docker.sock</Config>\n <Config Name=\"CLAW_GATEWAY_TEMPLATE_DIR\" Target=\"CLAW_GATEWAY_TEMPLATE_DIR\" Default=\"/unraid/templates-user\" Mode=\"\" Description=\"Unraid template directory path inside the gateway container\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/unraid/templates-user</Config>\n <Config Name=\"CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR\" Target=\"CLAW_GATEWAY_TEMPLATE_ARCHIVE_DIR\" Default=\"/appdata/template-archive\" Mode=\"\" Description=\"Archive location for removed worker templates\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">/appdata/template-archive</Config>\n <Config Name=\"CLAW_GATEWAY_WORKER_NETWORK\" Target=\"CLAW_GATEWAY_WORKER_NETWORK\" Default=\"claw_gateway\" Mode=\"\" Description=\"Docker network name shared by the gateway and workers\" Type=\"Variable\" Display=\"advanced\" Required=\"true\" Mask=\"false\">claw_gateway</Config>\n <Config Name=\"CLAW_GATEWAY_INHERITED_ENV\" Target=\"CLAW_GATEWAY_INHERITED_ENV\" Default=\"ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY\" Mode=\"\" Description=\"Comma-separated env vars copied from gateway into worker containers\" Type=\"Variable\" Display=\"advanced\" Required=\"false\" Mask=\"false\">ANTHROPIC_AUTH_TOKEN,ANTHROPIC_API_KEY</Config>\n <Config Name=\"CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS\" Target=\"CLAW_GATEWAY_WORKER_READY_TIMEOUT_SECS\" Default=\"15\" Mode=\"\" Description=\"Seconds to wait for a worker container to pass its health check\" Type=\"Variable\" Display=\"advanced\" Required=\"false\" Mask=\"false\">15</Config>\n <Config Name=\"ANTHROPIC_AUTH_TOKEN\" Target=\"ANTHROPIC_AUTH_TOKEN\" Default=\"\" Mode=\"\" Description=\"Claude subscription OAuth token (sk-ant-oat...) inherited by worker containers\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"true\"></Config>\n <Config Name=\"ANTHROPIC_API_KEY\" Target=\"ANTHROPIC_API_KEY\" Default=\"\" Mode=\"\" Description=\"Anthropic API key inherited by worker containers for direct API access\" Type=\"Variable\" Display=\"always\" Required=\"false\" Mask=\"true\"></Config>\n <TailscaleStateDir></TailscaleStateDir>\n</Container>\n",
current_timestamp_secs()
);
let destination = destination.as_ref();
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(destination, xml)?;
Ok(())
}
}
fn current_timestamp_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
#[derive(Debug)]
pub enum TemplateError {
Io(std::io::Error),
}
impl Display for TemplateError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(error) => write!(f, "{error}"),
}
}
}
impl std::error::Error for TemplateError {}
impl From<std::io::Error> for TemplateError {
fn from(value: std::io::Error) -> Self {
Self::Io(value)
}
}