use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::channels::telegram::state::TelegramState;
use teloxide::Bot;
pub(crate) fn skills_signature() -> u64 {
skills_signature_from(
&crate::brain::skills::user_skills_dir(),
&crate::services::ProjectService::projects_dir(),
)
}
pub(crate) fn skills_signature_from(user_skills: &Path, projects_dir: &Path) -> u64 {
let mut skills: Vec<(String, SystemTime, u64)> = Vec::new();
collect_skill_files(user_skills, "", &mut skills);
if let Ok(projects) = std::fs::read_dir(projects_dir) {
for project in projects.flatten() {
let file_name = project.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
collect_skill_files(&project.path().join("skills"), name, &mut skills);
}
}
skills.sort();
let mut hasher = DefaultHasher::new();
skills.len().hash(&mut hasher);
for (name, mtime, len) in skills {
name.hash(&mut hasher);
mtime.hash(&mut hasher);
len.hash(&mut hasher);
}
hasher.finish()
}
fn collect_skill_files(dir: &Path, prefix: &str, out: &mut Vec<(String, SystemTime, u64)>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Ok(meta) = std::fs::metadata(path.join("SKILL.md")) else {
continue;
};
let qualified = if prefix.is_empty() {
name.to_owned()
} else {
format!("{prefix}/{name}")
};
out.push((qualified, meta.modified().unwrap_or(UNIX_EPOCH), meta.len()));
}
}
pub(crate) async fn refresh_menus_if_skills_changed(bot: &Bot, telegram_state: &TelegramState) {
let signature = skills_signature();
if telegram_state.menu_skills_sig().await == Some(signature) {
return;
}
telegram_state.set_menu_skills_sig(signature).await;
super::agent::register_bot_commands(bot).await;
let solo_chats = telegram_state.solo_registered_chats().await;
if !solo_chats.is_empty() {
match crate::config::Config::load() {
Ok(cfg) => match cfg
.channels
.telegram
.allowed_users
.first()
.and_then(|s| s.parse::<i64>().ok())
{
Some(owner_id) => {
let commands = super::agent::collect_command_catalog();
for chat_id in &solo_chats {
super::menu_auto::publish_owner_menu(bot, *chat_id, owner_id, &commands)
.await;
}
}
None => tracing::debug!(
"Telegram: no owner configured — solo-owner menus skipped in skills refresh"
),
},
Err(e) => tracing::warn!(
"Telegram: config unreadable during skills refresh, {} solo menu(s) not \
re-published: {e}",
solo_chats.len()
),
}
}
tracing::info!(
"Telegram: skills changed ({signature:#x}) — command menus re-published ({} \
solo-owner group(s) refreshed)",
solo_chats.len()
);
}