pub mod dingtalk_auth;
pub mod feishu_auth;
use anyhow::{Context, Result};
use qrcode::QrCode;
use tracing::info;
pub fn display_qr_terminal(url: &str) -> Result<()> {
let code = QrCode::new(url.as_bytes()).context("failed to generate QR code")?;
use std::io::IsTerminal;
if !std::io::stdout().is_terminal() {
let _ = save_qr_png(&code);
return Ok(());
}
println!();
if !try_render_image(&code) {
let png_path = save_qr_png(&code);
#[cfg(target_os = "windows")]
if let Some(ref path) = png_path {
let _ = std::process::Command::new("cmd")
.args(["/C", "start", "", &path.display().to_string()])
.spawn();
println!(" QR code opened in image viewer.");
}
render_unicode(&code);
if let Some(path) = png_path {
println!(" If QR is distorted, open: file://{}", path.display());
}
}
println!();
println!("Scan the QR code above, or open this URL:");
println!(" {url}\n");
Ok(())
}
fn try_render_image(code: &QrCode) -> bool {
let term = std::env::var("TERM_PROGRAM").unwrap_or_default();
let is_iterm2 = term.contains("iTerm") || term.contains("WezTerm");
let is_kitty = std::env::var("KITTY_PID").is_ok();
let in_screen = std::env::var("STY").is_ok()
|| std::env::var("TERM")
.ok()
.is_some_and(|t| t.starts_with("screen"));
let in_tmux = std::env::var("TMUX").is_ok();
if in_screen || in_tmux {
return false;
}
if !is_iterm2 && !is_kitty {
return false;
}
let png_data = match qr_to_png(code) {
Some(data) => data,
None => return false,
};
let b64 = base64_encode_bytes(&png_data);
if is_kitty {
print!("\x1b_Gf=100,a=T;{}\x1b\\", b64);
} else {
print!(
"\x1b]1337;File=inline=1;size={};width=auto;preserveAspectRatio=1:{}\x07",
png_data.len(),
b64
);
}
println!();
true
}
fn qr_to_png(code: &QrCode) -> Option<Vec<u8>> {
let scale = 8u32;
let quiet = 4u32;
let qr_w = code.width() as u32;
let img_size = (qr_w + quiet * 2) * scale;
let mut pixels: Vec<u8> = Vec::with_capacity((img_size * img_size * 4) as usize);
for y in 0..img_size {
for x in 0..img_size {
let qx = (x / scale) as i32 - quiet as i32;
let qy = (y / scale) as i32 - quiet as i32;
let dark = if qx >= 0
&& qy >= 0
&& (qx as usize) < code.width()
&& (qy as usize) < code.width()
{
code[(qx as usize, qy as usize)] == qrcode::Color::Dark
} else {
false
};
let val = if dark { 0u8 } else { 255u8 };
pixels.extend_from_slice(&[val, val, val, 255]);
}
}
Some(encode_png_rgba(&pixels, img_size, img_size))
}
fn encode_png_rgba(pixels: &[u8], width: u32, height: u32) -> Vec<u8> {
use std::io::Write;
let row_len = (width as usize) * 4;
let mut raw_data = Vec::with_capacity((row_len + 1) * height as usize);
for row in 0..height as usize {
raw_data.push(0u8); raw_data.extend_from_slice(&pixels[row * row_len..(row + 1) * row_len]);
}
let mut encoder = flate2::write::ZlibEncoder::new(Vec::new(), flate2::Compression::default());
encoder.write_all(&raw_data).unwrap();
let compressed = encoder.finish().unwrap();
let mut png = Vec::new();
png.extend_from_slice(&[137, 80, 78, 71, 13, 10, 26, 10]);
let mut ihdr = Vec::new();
ihdr.extend_from_slice(&width.to_be_bytes());
ihdr.extend_from_slice(&height.to_be_bytes());
ihdr.push(8); ihdr.push(6); ihdr.push(0); ihdr.push(0); ihdr.push(0); write_png_chunk(&mut png, b"IHDR", &ihdr);
write_png_chunk(&mut png, b"IDAT", &compressed);
write_png_chunk(&mut png, b"IEND", &[]);
png
}
fn write_png_chunk(out: &mut Vec<u8>, chunk_type: &[u8; 4], data: &[u8]) {
let len = data.len() as u32;
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(chunk_type);
out.extend_from_slice(data);
let crc = crc32(&[chunk_type.as_slice(), data].concat());
out.extend_from_slice(&crc.to_be_bytes());
}
fn crc32(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFFFFFF;
for &byte in data {
crc ^= byte as u32;
for _ in 0..8 {
if crc & 1 != 0 {
crc = (crc >> 1) ^ 0xEDB88320;
} else {
crc >>= 1;
}
}
}
!crc
}
fn base64_encode_bytes(data: &[u8]) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(data)
}
fn save_qr_png(code: &QrCode) -> Option<std::path::PathBuf> {
let png_data = qr_to_png(code)?;
let path = std::env::temp_dir().join("rsclaw_qr.png");
std::fs::write(&path, &png_data).ok()?;
Some(path)
}
pub fn save_qr_to_path(url: &str) -> Result<std::path::PathBuf> {
let code = QrCode::new(url.as_bytes()).context("failed to generate QR code")?;
save_qr_png(&code).ok_or_else(|| anyhow::anyhow!("failed to write QR PNG to temp dir"))
}
fn render_unicode(code: &QrCode) {
let width = code.width();
let quiet = 2usize;
let total_w = width + quiet * 2;
let total_h = width + quiet * 2;
let is_dark = |x: i32, y: i32| -> bool {
let qx = x - quiet as i32;
let qy = y - quiet as i32;
if qx < 0 || qy < 0 || qx >= width as i32 || qy >= width as i32 {
return false;
}
code[(qx as usize, qy as usize)] == qrcode::Color::Dark
};
let mut y = 0i32;
while y < total_h as i32 {
let mut line = String::new();
for x in 0..total_w as i32 {
let top = is_dark(x, y);
let bot = is_dark(x, y + 1);
let ch = match (top, bot) {
(true, true) => '█',
(true, false) => '▀',
(false, true) => '▄',
(false, false) => ' ',
};
line.push(ch);
}
println!(" {line}");
y += 2;
}
}
#[allow(dead_code)]
fn render_ascii(code: &QrCode) {
let width = code.width();
let quiet = 2usize;
let total = width + quiet * 2;
let is_dark = |x: i32, y: i32| -> bool {
let qx = x - quiet as i32;
let qy = y - quiet as i32;
if qx < 0 || qy < 0 || qx >= width as i32 || qy >= width as i32 {
return false;
}
code[(qx as usize, qy as usize)] == qrcode::Color::Dark
};
for y in 0..total as i32 {
let mut line = String::with_capacity(total * 2 + 4);
line.push_str(" ");
for x in 0..total as i32 {
if is_dark(x, y) {
line.push_str("██");
} else {
line.push_str(" ");
}
}
println!("{line}");
}
}
pub fn save_token(platform: &str, token_data: &serde_json::Value) -> Result<()> {
let config_path = crate::config::loader::detect_config_path()
.ok_or_else(|| anyhow::anyhow!("no config file found"))?;
let raw = std::fs::read_to_string(&config_path)?;
let mut config: serde_json::Value = json5::from_str(&raw)?;
let channels = config
.as_object_mut()
.context("config root is not an object")?
.entry("channels")
.or_insert_with(|| serde_json::json!({}));
let channels = channels
.as_object_mut()
.context("channels is not an object")?;
let section = channels
.entry(platform)
.or_insert_with(|| serde_json::json!({}));
let section = section
.as_object_mut()
.context("channel section is not an object")?;
match platform {
"wechat" => {
if let Some(v) = token_data.get("bot_token") {
section.insert("botToken".to_owned(), v.clone());
}
if let Some(v) = token_data.get("ilink_bot_id") {
section.insert("botId".to_owned(), v.clone());
}
}
"feishu" => {
if let Some(v) = token_data.get("app_id") {
section.insert("appId".to_owned(), v.clone());
}
if let Some(v) = token_data.get("app_secret") {
section.insert("appSecret".to_owned(), v.clone());
}
if let Some(v) = token_data.get("brand") {
section.insert("brand".to_owned(), v.clone());
}
}
"dingtalk" => {
if let Some(v) = token_data.get("access_token") {
section.insert("accessToken".to_owned(), v.clone());
}
if let Some(v) = token_data.get("refresh_token") {
section.insert("refreshToken".to_owned(), v.clone());
}
}
_ => {
if let Some(obj) = token_data.as_object() {
for (k, v) in obj {
section.insert(k.clone(), v.clone());
}
}
}
}
section.entry("enabled").or_insert(serde_json::Value::Bool(true));
let json = serde_json::to_string_pretty(&config)?;
std::fs::write(&config_path, &json)?;
info!(platform, path = %config_path.display(), "auth token saved to config");
Ok(())
}
pub fn load_token(platform: &str) -> Option<serde_json::Value> {
if let Some(config_path) = crate::config::loader::detect_config_path() {
if let Ok(raw) = std::fs::read_to_string(&config_path) {
if let Ok(config) = json5::from_str::<serde_json::Value>(&raw) {
if let Some(section) = config.get("channels").and_then(|c| c.get(platform)) {
let mapped = reverse_map_token(platform, section);
if !mapped.as_object().is_some_and(|o| o.is_empty()) {
return Some(mapped);
}
}
}
}
}
None
}
fn reverse_map_token(platform: &str, section: &serde_json::Value) -> serde_json::Value {
let mut result = serde_json::Map::new();
match platform {
"wechat" => {
if let Some(v) = section.get("botToken") {
result.insert("bot_token".to_owned(), v.clone());
}
if let Some(v) = section.get("botId") {
result.insert("ilink_bot_id".to_owned(), v.clone());
}
}
"feishu" => {
if let Some(v) = section.get("appId") {
result.insert("app_id".to_owned(), v.clone());
}
if let Some(v) = section.get("appSecret") {
result.insert("app_secret".to_owned(), v.clone());
}
if let Some(v) = section.get("brand") {
result.insert("brand".to_owned(), v.clone());
}
}
"dingtalk" => {
if let Some(v) = section.get("accessToken") {
result.insert("access_token".to_owned(), v.clone());
}
if let Some(v) = section.get("refreshToken") {
result.insert("refresh_token".to_owned(), v.clone());
}
}
_ => {
if let Some(obj) = section.as_object() {
return serde_json::Value::Object(obj.clone());
}
}
}
serde_json::Value::Object(result)
}