use crate::config::{Config, opencrabs_home};
use super::TelegramState;
use super::send::{message_in_thread, photo_in_thread};
use teloxide::prelude::*;
use teloxide::types::{ChatId, InlineKeyboardButton, InlineKeyboardMarkup, InputFile};
const COWORK_PREFIX: &str = "cowork_";
#[derive(Debug, Clone)]
pub struct CoworkState {
pub user_id: i64,
pub chat_id: i64,
pub workspace_name: String,
pub session_id: String,
}
impl CoworkState {
pub fn new(user_id: i64, chat_id: i64, session_id: String) -> Self {
Self {
user_id,
chat_id,
workspace_name: String::new(),
session_id,
}
}
}
pub fn is_cowork_session(param: &str) -> bool {
param.starts_with(COWORK_PREFIX) && param.len() > COWORK_PREFIX.len()
}
pub fn parse_startgroup_param(param: &str) -> Option<&str> {
if is_cowork_session(param) {
Some(¶m[COWORK_PREFIX.len()..])
} else {
None
}
}
pub fn build_cowork_deep_link(bot_username: &str, session_id: &str) -> String {
format!(
"https://t.me/{}?startgroup={}{}",
bot_username, COWORK_PREFIX, session_id
)
}
pub fn build_invite_qr(invite_link: &str) -> Option<(Vec<u8>, std::path::PathBuf)> {
let png_bytes = crate::brain::tools::whatsapp_connect::render_qr_png(invite_link)?;
let dir = opencrabs_home().join("tmp");
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("cowork_invite_qr.png");
std::fs::write(&path, &png_bytes).ok()?;
Some((png_bytes, path))
}
pub fn auto_register_user(user_id: i64) -> Result<bool, String> {
let config = Config::load().map_err(|e| format!("Failed to load config: {e}"))?;
let id_str = user_id.to_string();
if config.channels.telegram.allowed_users.contains(&id_str) {
return Ok(false);
}
let mut users = config.channels.telegram.allowed_users.clone();
users.push(id_str);
Config::write_array("channels.telegram", "allowed_users", &users)
.map_err(|e| format!("Failed to write allowed_users: {e}"))?;
Ok(true)
}
pub async fn is_cowork_group(chat_id: i64, state: &TelegramState) -> bool {
state.is_cowork_group(chat_id).await
}
pub async fn handle_cowork_command(
bot: &Bot,
_msg: &Message,
state: &TelegramState,
user_id: i64,
chat_id: i64,
thread_id: Option<teloxide::types::ThreadId>,
) -> Result<(), teloxide::RequestError> {
if let Some(existing) = state.get_cowork_state(user_id).await {
let bot_username = state
.bot_username()
.await
.unwrap_or_else(|| "opencrabsbot".to_string());
let deep_link = build_cowork_deep_link(&bot_username, &existing.session_id);
let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url(
"🦀 Add to Group".to_string(),
deep_link.parse().unwrap(),
)]]);
let text = if existing.workspace_name.is_empty() {
"You have a pending /cowork setup. What should we call your workspace?".to_string()
} else {
format!(
"Workspace: **{}**\n\nTap below to add me to any group with your friends.\n\
Don't have one yet? Create a group in Telegram, invite your friends, then come back and tap this button.",
existing.workspace_name
)
};
message_in_thread(bot, ChatId(chat_id), thread_id, text)
.reply_markup(keyboard)
.await?;
return Ok(());
}
let session_id = uuid::Uuid::new_v4().to_string()[..8].to_string();
state.start_cowork(user_id, chat_id, session_id).await;
message_in_thread(
bot,
ChatId(chat_id),
thread_id,
"🦀 **Cowork Setup**\n\nWhat should we call your workspace?",
)
.await?;
Ok(())
}
pub async fn handle_workspace_name(
bot: &Bot,
state: &TelegramState,
user_id: i64,
chat_id: i64,
workspace_name: &str,
thread_id: Option<teloxide::types::ThreadId>,
) -> Result<(), teloxide::RequestError> {
let Some(mut cowork) = state.set_workspace_name(user_id, workspace_name).await else {
return Ok(());
};
cowork.workspace_name = workspace_name.to_string();
let bot_username = state
.bot_username()
.await
.unwrap_or_else(|| "opencrabsbot".to_string());
let deep_link = build_cowork_deep_link(&bot_username, &cowork.session_id);
let keyboard = InlineKeyboardMarkup::new(vec![vec![InlineKeyboardButton::url(
"🦀 Add to Group".to_string(),
deep_link.parse().unwrap(),
)]]);
let text = format!(
"Workspace: **{}**\n\nTap below to add me to any group with your friends.\n\
Don't have one yet? Create a group in Telegram, invite your friends, then come back and tap this button.\n\n\
I'll auto-register everyone when they send a message.",
workspace_name
);
message_in_thread(bot, ChatId(chat_id), thread_id, text)
.reply_markup(keyboard)
.await?;
Ok(())
}
pub async fn handle_cowork_group_join(
bot: &Bot,
msg: &Message,
state: &TelegramState,
param: &str,
thread_id: Option<teloxide::types::ThreadId>,
) -> Result<(), teloxide::RequestError> {
let Some(session_id) = parse_startgroup_param(param) else {
return Ok(());
};
let group_chat_id = msg.chat.id.0;
let cowork = state.take_cowork_by_session(session_id).await;
state.add_cowork_group(group_chat_id).await;
let invite_result = bot.create_chat_invite_link(ChatId(group_chat_id)).await;
match invite_result {
Ok(link) => {
let invite_url = &link.invite_link;
if let Some((_png_bytes, qr_path)) = build_invite_qr(invite_url) {
if let Some(ref cowork_state) = cowork {
let user_chat = ChatId(cowork_state.chat_id);
let _ = photo_in_thread(bot, user_chat, None, InputFile::file(qr_path)).await;
let _ = message_in_thread(
bot,
user_chat,
None,
format!(
"🦀 **Workspace created!**\n\n\
Group: **{}**\n\
Invite link: {}\n\n\
Share the QR or link with your team. \
Their IDs auto-register when they join.",
cowork_state.workspace_name, invite_url
),
)
.await;
}
}
let _ = message_in_thread(
bot,
ChatId(group_chat_id),
thread_id,
"🦀 Welcome to your Cowork workspace!\n\n\
Share the invite link with your team. \
@mention me anytime to chat.",
)
.await;
}
Err(e) => {
tracing::error!("Cowork: failed to create invite link: {}", e);
let _ = message_in_thread(
bot,
ChatId(group_chat_id),
thread_id,
"Failed to generate invite link. Make sure I'm an admin in this group.",
)
.await;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_startgroup_valid() {
assert_eq!(parse_startgroup_param("cowork_abc123"), Some("abc123"));
}
#[test]
fn parse_startgroup_not_cowork() {
assert_eq!(parse_startgroup_param("other_param"), None);
}
#[test]
fn parse_startgroup_empty() {
assert_eq!(parse_startgroup_param(""), None);
}
#[test]
fn parse_startgroup_just_prefix() {
assert_eq!(parse_startgroup_param("cowork_"), None);
}
#[test]
fn is_cowork_session_true() {
assert!(is_cowork_session("cowork_xxx"));
}
#[test]
fn is_cowork_session_false() {
assert!(!is_cowork_session("other"));
}
#[test]
fn build_deep_link_format() {
let link = build_cowork_deep_link("mybot", "abc123");
assert_eq!(link, "https://t.me/mybot?startgroup=cowork_abc123");
}
#[test]
fn build_deep_link_with_bot_suffix() {
let link = build_cowork_deep_link("team_crab_bot", "xyz");
assert_eq!(link, "https://t.me/team_crab_bot?startgroup=cowork_xyz");
}
#[test]
fn cowork_state_lifecycle() {
let state = CoworkState::new(123, 456, "abc".to_string());
assert_eq!(state.user_id, 123);
assert_eq!(state.chat_id, 456);
assert!(state.workspace_name.is_empty());
assert_eq!(state.session_id, "abc");
}
#[test]
fn invite_qr_generation() {
let result = build_invite_qr("https://t.me/+AbCdEfGh");
assert!(result.is_some());
let (bytes, path) = result.unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
assert!(path.exists());
let _ = std::fs::remove_file(path);
}
}