From 1e83b50e3ff996f7ecb708ad568b9ca76e9803de Mon Sep 17 00:00:00 2001 From: syui Date: Mon, 16 Jun 2025 22:09:04 +0900 Subject: [PATCH] test cli stream --- Cargo.toml | 1 + my-blog/config.toml | 4 +- my-blog/oauth/.env.production | 20 +++ oauth/src/App.tsx | 147 +++++++++++++++----- oauth/src/config/app.ts | 6 +- scpt/delete-chat-records.zsh | 16 +-- src/commands/auth.rs | 242 ++++++++++++++++++++++++++++++-- src/commands/build.rs | 106 ++++++++++++++ src/commands/init.rs | 16 ++- src/commands/stream.rs | 253 ++++++++++++++++++++++++++++++---- src/config.rs | 14 ++ src/main.rs | 33 ++++- 12 files changed, 776 insertions(+), 82 deletions(-) create mode 100644 my-blog/oauth/.env.production diff --git a/Cargo.toml b/Cargo.toml index ac7ed11..8ec70c9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -49,6 +49,7 @@ regex = "1.0" tokio-tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots", "connect"], default-features = false } futures-util = "0.3" tungstenite = { version = "0.21", features = ["rustls-tls-webpki-roots"], default-features = false } +rpassword = "7.3" [dev-dependencies] tempfile = "3.14" diff --git a/my-blog/config.toml b/my-blog/config.toml index 439e8c9..91e9b37 100644 --- a/my-blog/config.toml +++ b/my-blog/config.toml @@ -19,7 +19,7 @@ provider = "ollama" model = "gemma3:4b" host = "https://ollama.syui.ai" system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" -ai_handle = "ai.syui.ai" +handle = "ai.syui.ai" #num_predict = 200 [oauth] @@ -27,5 +27,5 @@ json = "client-metadata.json" redirect = "oauth/callback" admin = "ai.syui.ai" collection = "ai.syui.log" -pds = "syu.is" # Network configuration: "bsky.social" for Bluesky, "syu.is" for independent network +pds = "syu.is" handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"] diff --git a/my-blog/oauth/.env.production b/my-blog/oauth/.env.production new file mode 100644 index 0000000..af1be03 --- /dev/null +++ b/my-blog/oauth/.env.production @@ -0,0 +1,20 @@ +# Production environment variables +VITE_APP_HOST=https://syui.ai +VITE_OAUTH_CLIENT_ID=https://syui.ai/client-metadata.json +VITE_OAUTH_REDIRECT_URI=https://syui.ai/oauth/callback + +# Handle-based Configuration (DIDs resolved at runtime) +VITE_ATPROTO_PDS=syu.is +VITE_ADMIN_HANDLE=ai.syui.ai +VITE_AI_HANDLE=ai.syui.ai +VITE_OAUTH_COLLECTION=ai.syui.log +VITE_ATPROTO_WEB_URL=https://bsky.app +VITE_ATPROTO_HANDLE_LIST=["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is", "ai.ai"] + +# AI Configuration +VITE_AI_ENABLED=true +VITE_AI_ASK_AI=true +VITE_AI_PROVIDER=ollama +VITE_AI_MODEL=gemma3:4b +VITE_AI_HOST=https://ollama.syui.ai +VITE_AI_SYSTEM_PROMPT="あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" diff --git a/oauth/src/App.tsx b/oauth/src/App.tsx index 6e786ab..0a0fcc3 100644 --- a/oauth/src/App.tsx +++ b/oauth/src/App.tsx @@ -31,7 +31,33 @@ function App() { const [aiCommentRecords, setAiCommentRecords] = useState([]); const [aiProfile, setAiProfile] = useState(null); + const [adminDid, setAdminDid] = useState(null); + const [aiDid, setAiDid] = useState(null); + + // ハンドルからDIDを解決する関数 + const resolveHandleToDid = async (handle: string): Promise => { + try { + const profile = await import('./utils/pds-detection').then(m => m.getProfileForUser(handle)); + return profile?.did || null; + } catch { + return null; + } + }; + useEffect(() => { + // 管理者とAIのDIDを解決 + const resolveAdminAndAiDids = async () => { + const [resolvedAdminDid, resolvedAiDid] = await Promise.all([ + resolveHandleToDid(appConfig.adminHandle), + resolveHandleToDid(appConfig.aiHandle) + ]); + + setAdminDid(resolvedAdminDid || appConfig.adminDid); + setAiDid(resolvedAiDid || appConfig.aiDid); + }; + + resolveAdminAndAiDids(); + // Setup Jetstream WebSocket for real-time comments (optional) const setupJetstream = () => { try { @@ -83,13 +109,28 @@ function App() { return false; }; - // キャッシュがなければ、ATProtoから取得(認証状態に関係なく) - if (!loadCachedComments()) { - loadAllComments(); // URLフィルタリングを無効にして全コメント表示 - } + // DID解決が完了してからコメントとチャット履歴を読み込む + const loadDataAfterDidResolution = () => { + // キャッシュがなければ、ATProtoから取得(認証状態に関係なく) + if (!loadCachedComments()) { + loadAllComments(); // URLフィルタリングを無効にして全コメント表示 + } + + // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示) + loadAiChatHistory(); + }; - // Load AI chat history (認証状態に関係なく、全ユーザーのチャット履歴を表示) - loadAiChatHistory(); + // Wait for DID resolution before loading data + if (adminDid && aiDid) { + loadDataAfterDidResolution(); + } else { + // Wait a bit and try again + setTimeout(() => { + if (adminDid && aiDid) { + loadDataAfterDidResolution(); + } + }, 1000); + } // Load AI profile from handle const loadAiProfile = async () => { @@ -198,7 +239,7 @@ function App() { loadAiChatHistory(); // Load user list records if admin - if (userProfile.did === appConfig.adminDid) { + if (userProfile.did === adminDid) { loadUserListRecords(); } @@ -224,7 +265,7 @@ function App() { loadAllComments(); // Load user list records if admin - if (verifiedUser.did === appConfig.adminDid) { + if (verifiedUser.did === adminDid) { loadUserListRecords(); } } @@ -244,6 +285,15 @@ function App() { }; }, []); + // DID解決完了時にデータを再読み込み + useEffect(() => { + if (adminDid && aiDid) { + console.log('DIDs resolved, loading comments and chat history...'); + loadAllComments(); + loadAiChatHistory(); + } + }, [adminDid, aiDid]); + const getUserProfile = async (did: string, handle: string): Promise => { try { const agent = atprotoOAuthService.getAgent(); @@ -281,21 +331,21 @@ function App() { const loadAiChatHistory = async () => { try { // Load all chat records from users in admin's user list - const adminDid = appConfig.adminDid; + const currentAdminDid = adminDid || appConfig.adminDid; const atprotoApi = appConfig.atprotoApi || 'https://bsky.social'; const collections = getCollectionNames(appConfig.collections.base); // First, get user list from admin using their proper PDS let adminPdsEndpoint; try { - const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(adminDid)); + const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); adminPdsEndpoint = config.pdsApi; } catch { adminPdsEndpoint = atprotoApi; } - const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`); + const userListResponse = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(collections.user)}&limit=100`); if (!userListResponse.ok) { setAiChatHistory([]); @@ -318,7 +368,7 @@ function App() { }); // Always include admin DID to check admin's own chats - allUserDids.push(adminDid); + allUserDids.push(currentAdminDid); const userDids = [...new Set(allUserDids)]; @@ -386,7 +436,7 @@ function App() { // Load AI generated content from admin DID const loadAIGeneratedContent = async () => { try { - const adminDid = appConfig.adminDid; + const currentAdminDid = adminDid || appConfig.adminDid; const atprotoApi = appConfig.atprotoApi || 'https://bsky.social'; const collections = getCollectionNames(appConfig.collections.base); @@ -505,32 +555,40 @@ function App() { const loadUsersFromRecord = async () => { try { // 管理者のユーザーリストを取得 using proper PDS detection - const adminDid = appConfig.adminDid; + const currentAdminDid = adminDid || appConfig.adminDid; + console.log('loadUsersFromRecord: Using Admin DID:', currentAdminDid); // Use per-user PDS detection for admin's records let adminPdsEndpoint; try { - const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(adminDid)); + const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); adminPdsEndpoint = config.pdsApi; } catch { adminPdsEndpoint = 'https://bsky.social'; // Fallback } - const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`); + const userCollectionUrl = `${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`; + console.log('loadUsersFromRecord: Fetching from URL:', userCollectionUrl); + + const response = await fetch(userCollectionUrl); + + console.log('loadUsersFromRecord: Response status:', response.status); if (!response.ok) { - // Failed to fetch user list from admin, using default users + console.log('loadUsersFromRecord: Failed to fetch, using default users'); return getDefaultUsers(); } const data = await response.json(); const userRecords = data.records || []; - // User records found + console.log('loadUsersFromRecord: Found user records:', userRecords.length); if (userRecords.length === 0) { - // No user records found, using default users - return getDefaultUsers(); + console.log('loadUsersFromRecord: No user records found, using default users'); + const defaultUsers = getDefaultUsers(); + console.log('loadUsersFromRecord: Default users:', defaultUsers); + return defaultUsers; } // レコードからユーザーリストを構築し、プレースホルダーDIDを実際のDIDに解決 @@ -562,7 +620,7 @@ function App() { } } - // Loaded and resolved users from admin records + console.log('loadUsersFromRecord: Resolved users:', allUsers); return allUsers; } catch (err) { // Failed to load users from records, using defaults @@ -574,19 +632,19 @@ function App() { const loadUserListRecords = async () => { try { // Loading user list records using proper PDS detection - const adminDid = appConfig.adminDid; + const currentAdminDid = adminDid || appConfig.adminDid; // Use per-user PDS detection for admin's records let adminPdsEndpoint; try { - const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(adminDid)); + const resolved = await import('./utils/pds-detection').then(m => m.resolvePdsFromRepo(currentAdminDid)); const config = await import('./utils/pds-detection').then(m => m.getNetworkConfigFromPdsEndpoint(resolved.pds)); adminPdsEndpoint = config.pdsApi; } catch { adminPdsEndpoint = 'https://bsky.social'; // Fallback } - const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(adminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`); + const response = await fetch(`${adminPdsEndpoint}/xrpc/com.atproto.repo.listRecords?repo=${encodeURIComponent(currentAdminDid)}&collection=${encodeURIComponent(getCollectionNames(appConfig.collections.base).user)}&limit=100`); if (!response.ok) { // Failed to fetch user list records @@ -611,21 +669,27 @@ function App() { }; const getDefaultUsers = () => { + const currentAdminDid = adminDid || appConfig.adminDid; const defaultUsers = [ // Default admin user - { did: appConfig.adminDid, handle: 'syui.ai', pds: 'https://bsky.social' }, + { did: currentAdminDid, handle: appConfig.adminHandle, pds: 'https://syu.is' }, ]; // 現在ログインしているユーザーも追加(重複チェック) if (user && user.did && user.handle && !defaultUsers.find(u => u.did === user.did)) { + // Detect PDS based on handle + const userPds = user.handle.endsWith('.syu.is') ? 'https://syu.is' : + user.handle.endsWith('.syui.ai') ? 'https://syu.is' : + 'https://bsky.social'; + defaultUsers.push({ did: user.did, handle: user.handle, - pds: user.handle.endsWith('.syu.is') ? 'https://syu.is' : 'https://bsky.social' + pds: userPds }); } - // Default users list (including current user) + console.log('getDefaultUsers: Returning default users:', defaultUsers); return defaultUsers; }; @@ -635,6 +699,7 @@ function App() { // ユーザーリストを動的に取得 const knownUsers = await loadUsersFromRecord(); + console.log('loadAllComments: Using users for comment fetching:', knownUsers); const allComments = []; @@ -888,7 +953,7 @@ function App() { // 管理者チェック const isAdmin = (user: User | null): boolean => { - return user?.did === appConfig.adminDid; + return user?.did === adminDid || user?.did === appConfig.adminDid; }; // ユーザーリスト投稿 @@ -1182,6 +1247,25 @@ function App() { return (
+ {/* Debug Info */} +
+ Debug Info:
+ Admin Handle: {appConfig.adminHandle}
+ Admin DID (resolved): {adminDid || 'resolving...'}
+ Admin DID (config): {appConfig.adminDid}
+ AI Handle: {appConfig.aiHandle}
+ AI DID (resolved): {aiDid || 'resolving...'}
+ AI DID (config): {appConfig.aiDid}
+ Collection Base: {appConfig.collections.base}
+ User Collection: {appConfig.collections.base}.user
+ DIDs Resolved: {adminDid && aiDid ? 'Yes' : 'No'} +
@@ -1442,7 +1526,7 @@ function App() { aiChatHistory.map((record, index) => { // For AI responses, use AI DID; for user questions, use the actual author const isAiResponse = record.value.type === 'answer'; - const displayDid = isAiResponse ? appConfig.aiDid : record.value.author?.did; + const displayDid = isAiResponse ? (aiDid || appConfig.aiDid) : record.value.author?.did; const displayHandle = isAiResponse ? (aiProfile?.handle || 'yui.syui.ai') : record.value.author?.handle; const displayName = isAiResponse ? 'AI' : (record.value.author?.displayName || record.value.author?.handle); @@ -1558,8 +1642,9 @@ function App() { className="comment-avatar" ref={(img) => { // Fetch AI avatar - if (img && appConfig.aiDid) { - fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(appConfig.aiDid)}`) + const currentAiDid = aiDid || appConfig.aiDid; + if (img && currentAiDid) { + fetch(`${appConfig.bskyPublicApi}/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(currentAiDid)}`) .then(res => res.json()) .then(data => { if (data.avatar && img) { diff --git a/oauth/src/config/app.ts b/oauth/src/config/app.ts index 18a11ba..7bac2fc 100644 --- a/oauth/src/config/app.ts +++ b/oauth/src/config/app.ts @@ -84,10 +84,12 @@ function extractRkeyFromUrl(): string | undefined { // Get application configuration from environment variables export function getAppConfig(): AppConfig { const host = import.meta.env.VITE_APP_HOST || 'https://log.syui.ai'; + const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'ai.syui.ai'; + const aiHandle = import.meta.env.VITE_AI_HANDLE || 'ai.syui.ai'; + + // DIDsはハンドルから実行時に解決される(フォールバック用のみ保持) const adminDid = import.meta.env.VITE_ADMIN_DID || 'did:plc:uqzpqmrjnptsxezjx4xuh2mn'; - const adminHandle = import.meta.env.VITE_ADMIN_HANDLE || 'syui.ai'; const aiDid = import.meta.env.VITE_AI_DID || 'did:plc:4hqjfn7m6n5hno3doamuhgef'; - const aiHandle = import.meta.env.VITE_AI_HANDLE || 'yui.syui.ai'; const aiDisplayName = import.meta.env.VITE_AI_DISPLAY_NAME || 'ai'; const aiAvatar = import.meta.env.VITE_AI_AVATAR || ''; const aiDescription = import.meta.env.VITE_AI_DESCRIPTION || ''; diff --git a/scpt/delete-chat-records.zsh b/scpt/delete-chat-records.zsh index d832cb6..c11ee93 100755 --- a/scpt/delete-chat-records.zsh +++ b/scpt/delete-chat-records.zsh @@ -3,16 +3,16 @@ set -e cb=ai.syui.log -cl=( $cb.chat $cb.chat.comment $cb.chat.lang ) -f=~/.config/syui/ai/bot/token.json +cl=( $cb.user ) +f=~/.config/syui/ai/log/config.json default_collection="ai.syui.log.chat.comment" -default_pds="bsky.social" -default_did=`cat $f|jq -r .did` -default_token=`cat $f|jq -r .accessJwt` -default_refresh=`cat $f|jq -r .refreshJwt` -curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f -default_token=`cat $f|jq -r .accessJwt` +default_pds="syu.is" +default_did=`cat $f|jq -r .admin.did` +default_token=`cat $f|jq -r .admin.access_jwt` +default_refresh=`cat $f|jq -r .admin.refresh_jwt` +#curl -sL -X POST -H "Content-Type: application/json" -H "Authorization: Bearer $default_refresh" https://$default_pds/xrpc/com.atproto.server.refreshSession >! $f +#default_token=`cat $f|jq -r .admin.access_jwt` collection=${1:-$default_collection} pds=${2:-$default_pds} did=${3:-$default_did} diff --git a/src/commands/auth.rs b/src/commands/auth.rs index 756ad1c..4207f63 100644 --- a/src/commands/auth.rs +++ b/src/commands/auth.rs @@ -87,6 +87,122 @@ fn get_config_path() -> Result { } pub async fn init() -> Result<()> { + init_with_pds(None).await +} + +pub async fn init_with_options( + pds_override: Option, + handle_override: Option, + use_password: bool, + access_jwt_override: Option, + refresh_jwt_override: Option +) -> Result<()> { + println!("{}", "🔐 Initializing ATProto authentication...".cyan()); + + let config_path = get_config_path()?; + + if config_path.exists() { + println!("{}", "⚠️ Configuration already exists. Use 'ailog auth logout' to reset.".yellow()); + return Ok(()); + } + + // Validate options + if let (Some(_), Some(_)) = (&access_jwt_override, &refresh_jwt_override) { + if use_password { + println!("{}", "⚠️ Cannot use both --password and JWT tokens. Choose one method.".yellow()); + return Ok(()); + } + } else if access_jwt_override.is_some() || refresh_jwt_override.is_some() { + println!("{}", "❌ Both --access-jwt and --refresh-jwt must be provided together.".red()); + return Ok(()); + } + + println!("{}", "📋 Please provide your ATProto credentials:".cyan()); + + // Get handle + let handle = if let Some(h) = handle_override { + h + } else { + print!("Handle (e.g., your.handle.bsky.social): "); + std::io::Write::flush(&mut std::io::stdout())?; + let mut input = String::new(); + std::io::stdin().read_line(&mut input)?; + input.trim().to_string() + }; + + // Determine PDS URL + let pds_url = if let Some(override_pds) = pds_override { + if override_pds.starts_with("http") { + override_pds + } else { + format!("https://{}", override_pds) + } + } else { + if handle.ends_with(".syu.is") { + "https://syu.is".to_string() + } else { + "https://bsky.social".to_string() + } + }; + + println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan()); + + // Get credentials + let (access_jwt, refresh_jwt) = if let (Some(access), Some(refresh)) = (access_jwt_override, refresh_jwt_override) { + println!("{}", "🔑 Using provided JWT tokens".cyan()); + (access, refresh) + } else if use_password { + println!("{}", "🔒 Using password authentication".cyan()); + authenticate_with_password(&handle, &pds_url).await? + } else { + // Interactive JWT input (legacy behavior) + print!("Access JWT: "); + std::io::Write::flush(&mut std::io::stdout())?; + let mut access_jwt = String::new(); + std::io::stdin().read_line(&mut access_jwt)?; + let access_jwt = access_jwt.trim().to_string(); + + print!("Refresh JWT: "); + std::io::Write::flush(&mut std::io::stdout())?; + let mut refresh_jwt = String::new(); + std::io::stdin().read_line(&mut refresh_jwt)?; + let refresh_jwt = refresh_jwt.trim().to_string(); + + (access_jwt, refresh_jwt) + }; + + // Resolve DID from handle + println!("{}", "🔍 Resolving DID from handle...".cyan()); + let did = resolve_did_with_pds(&handle, &pds_url).await?; + + // Create config + let config = AuthConfig { + admin: AdminConfig { + did: did.clone(), + handle: handle.clone(), + access_jwt, + refresh_jwt, + pds: pds_url, + }, + jetstream: JetstreamConfig { + url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(), + collections: vec!["ai.syui.log".to_string()], + }, + collections: generate_collection_config(), + }; + + // Save config + let config_json = serde_json::to_string_pretty(&config)?; + fs::write(&config_path, config_json)?; + + println!("{}", "✅ Authentication configured successfully!".green()); + println!("📁 Config saved to: {}", config_path.display()); + println!("👤 Authenticated as: {} ({})", handle, did); + + Ok(()) +} + +pub async fn init_with_pds(pds_override: Option) -> Result<()> { println!("{}", "🔐 Initializing ATProto authentication...".cyan()); let config_path = get_config_path()?; @@ -117,9 +233,28 @@ pub async fn init() -> Result<()> { std::io::stdin().read_line(&mut refresh_jwt)?; let refresh_jwt = refresh_jwt.trim().to_string(); + // Determine PDS URL + let pds_url = if let Some(override_pds) = pds_override { + // Use provided PDS override + if override_pds.starts_with("http") { + override_pds + } else { + format!("https://{}", override_pds) + } + } else { + // Auto-detect from handle suffix + if handle.ends_with(".syu.is") { + "https://syu.is".to_string() + } else { + "https://bsky.social".to_string() + } + }; + + println!("{}", format!("🌐 Using PDS: {}", pds_url).cyan()); + // Resolve DID from handle println!("{}", "🔍 Resolving DID from handle...".cyan()); - let did = resolve_did(&handle).await?; + let did = resolve_did_with_pds(&handle, &pds_url).await?; // Create config let config = AuthConfig { @@ -128,11 +263,7 @@ pub async fn init() -> Result<()> { handle: handle.clone(), access_jwt, refresh_jwt, - pds: if handle.ends_with(".syu.is") { - "https://syu.is".to_string() - } else { - "https://bsky.social".to_string() - }, + pds: pds_url, }, jetstream: JetstreamConfig { url: "wss://jetstream2.us-east.bsky.network/subscribe".to_string(), @@ -178,6 +309,93 @@ async fn resolve_did(handle: &str) -> Result { Ok(did.to_string()) } +async fn resolve_did_with_pds(handle: &str, pds_url: &str) -> Result { + let client = reqwest::Client::new(); + + // Try to use the PDS API first + let api_base = if pds_url.contains("syu.is") { + "https://bsky.syu.is" + } else if pds_url.contains("bsky.social") { + "https://public.api.bsky.app" + } else { + // For custom PDS, try to construct API URL + pds_url + }; + + let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}", + api_base, urlencoding::encode(handle)); + + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Failed to resolve handle using PDS {}: {}", pds_url, response.status())); + } + + let profile: serde_json::Value = response.json().await?; + let did = profile["did"].as_str() + .ok_or_else(|| anyhow::anyhow!("DID not found in profile response"))?; + + Ok(did.to_string()) +} + +async fn authenticate_with_password(handle: &str, pds_url: &str) -> Result<(String, String)> { + use std::io::{self, Write}; + + // Get password securely + print!("Password: "); + io::stdout().flush()?; + let password = rpassword::read_password() + .context("Failed to read password")?; + + if password.is_empty() { + return Err(anyhow::anyhow!("Password cannot be empty")); + } + + println!("{}", "🔐 Authenticating with ATProto server...".cyan()); + + let client = reqwest::Client::new(); + let auth_url = format!("{}/xrpc/com.atproto.server.createSession", pds_url); + + let auth_request = serde_json::json!({ + "identifier": handle, + "password": password + }); + + let response = client + .post(&auth_url) + .header("Content-Type", "application/json") + .json(&auth_request) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + + if status.as_u16() == 401 { + return Err(anyhow::anyhow!("Authentication failed: Invalid handle or password")); + } else if status.as_u16() == 400 { + return Err(anyhow::anyhow!("Authentication failed: Bad request (check handle format)")); + } else { + return Err(anyhow::anyhow!("Authentication failed: {} - {}", status, error_text)); + } + } + + let auth_response: serde_json::Value = response.json().await?; + + let access_jwt = auth_response["accessJwt"].as_str() + .ok_or_else(|| anyhow::anyhow!("No access JWT in response"))? + .to_string(); + + let refresh_jwt = auth_response["refreshJwt"].as_str() + .ok_or_else(|| anyhow::anyhow!("No refresh JWT in response"))? + .to_string(); + + println!("{}", "✅ Password authentication successful".green()); + + Ok((access_jwt, refresh_jwt)) +} + pub async fn status() -> Result<()> { let config_path = get_config_path()?; @@ -200,9 +418,17 @@ pub async fn status() -> Result<()> { // Test API access println!("\n{}", "🧪 Testing API access...".cyan()); - match test_api_access(&config).await { + match test_api_access_with_auth(&config).await { Ok(_) => println!("{}", "✅ API access successful".green()), - Err(e) => println!("{}", format!("❌ API access failed: {}", e).red()), + Err(e) => { + println!("{}", format!("❌ Authenticated API access failed: {}", e).red()); + // Fallback to public API test + println!("{}", "🔄 Trying public API access...".cyan()); + match test_api_access(&config).await { + Ok(_) => println!("{}", "✅ Public API access successful".green()), + Err(e2) => println!("{}", format!("❌ Public API access also failed: {}", e2).red()), + } + } } Ok(()) diff --git a/src/commands/build.rs b/src/commands/build.rs index 985a2b1..40f1a63 100644 --- a/src/commands/build.rs +++ b/src/commands/build.rs @@ -1,6 +1,7 @@ use anyhow::Result; use colored::Colorize; use std::path::PathBuf; +use std::fs; use crate::generator::Generator; use crate::config::Config; @@ -10,6 +11,12 @@ pub async fn execute(path: PathBuf) -> Result<()> { // Load configuration let config = Config::load(&path)?; + // Generate OAuth .env.production if oauth directory exists + let oauth_dir = path.join("oauth"); + if oauth_dir.exists() { + generate_oauth_env(&path, &config)?; + } + // Create generator let generator = Generator::new(path, config)?; @@ -18,5 +25,104 @@ pub async fn execute(path: PathBuf) -> Result<()> { println!("{}", "Build completed successfully!".green().bold()); + Ok(()) +} + +fn generate_oauth_env(path: &PathBuf, config: &Config) -> Result<()> { + let oauth_dir = path.join("oauth"); + let env_file = oauth_dir.join(".env.production"); + + // Extract configuration values + let base_url = &config.site.base_url; + let oauth_json = config.oauth.as_ref() + .and_then(|o| o.json.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("client-metadata.json"); + let oauth_redirect = config.oauth.as_ref() + .and_then(|o| o.redirect.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("oauth/callback"); + let admin_handle = config.oauth.as_ref() + .and_then(|o| o.admin.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("ai.syui.ai"); + let ai_handle = config.ai.as_ref() + .and_then(|a| a.handle.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("ai.syui.ai"); + let collection = config.oauth.as_ref() + .and_then(|o| o.collection.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("ai.syui.log"); + let pds = config.oauth.as_ref() + .and_then(|o| o.pds.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("syu.is"); + let handle_list = config.oauth.as_ref() + .and_then(|o| o.handle_list.as_ref()) + .map(|list| format!("{:?}", list)) + .unwrap_or_else(|| "[\"syui.syui.ai\",\"yui.syui.ai\",\"ai.syui.ai\"]".to_string()); + + // AI configuration + let ai_enabled = config.ai.as_ref().map(|a| a.enabled).unwrap_or(true); + let ai_ask_ai = config.ai.as_ref().and_then(|a| a.ask_ai).unwrap_or(true); + let ai_provider = config.ai.as_ref() + .and_then(|a| a.provider.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("ollama"); + let ai_model = config.ai.as_ref() + .and_then(|a| a.model.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("gemma3:4b"); + let ai_host = config.ai.as_ref() + .and_then(|a| a.host.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("https://ollama.syui.ai"); + let ai_system_prompt = config.ai.as_ref() + .and_then(|a| a.system_prompt.as_ref()) + .map(|s| s.as_str()) + .unwrap_or("あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。"); + + let env_content = format!( +r#"# Production environment variables +VITE_APP_HOST={} +VITE_OAUTH_CLIENT_ID={}/{} +VITE_OAUTH_REDIRECT_URI={}/{} + +# Handle-based Configuration (DIDs resolved at runtime) +VITE_ATPROTO_PDS={} +VITE_ADMIN_HANDLE={} +VITE_AI_HANDLE={} +VITE_OAUTH_COLLECTION={} +VITE_ATPROTO_WEB_URL=https://bsky.app +VITE_ATPROTO_HANDLE_LIST={} + +# AI Configuration +VITE_AI_ENABLED={} +VITE_AI_ASK_AI={} +VITE_AI_PROVIDER={} +VITE_AI_MODEL={} +VITE_AI_HOST={} +VITE_AI_SYSTEM_PROMPT="{}" +"#, + base_url, + base_url, oauth_json, + base_url, oauth_redirect, + pds, + admin_handle, + ai_handle, + collection, + handle_list, + ai_enabled, + ai_ask_ai, + ai_provider, + ai_model, + ai_host, + ai_system_prompt + ); + + fs::write(&env_file, env_content)?; + println!(" {} oauth/.env.production", "Generated".cyan()); + Ok(()) } \ No newline at end of file diff --git a/src/commands/init.rs b/src/commands/init.rs index 54aa76b..afb3c64 100644 --- a/src/commands/init.rs +++ b/src/commands/init.rs @@ -37,9 +37,23 @@ highlight_code = true minify = false [ai] -enabled = false +enabled = true auto_translate = false comment_moderation = false +ask_ai = true +provider = "ollama" +model = "gemma3:4b" +host = "https://ollama.syui.ai" +system_prompt = "あなたは6歳の女の子アイです。明るく元気で、ちょっとおっちょこちょい。自分のことは「アイ」って呼びます。白と金のワンピース姿。 相手のことが大好きで、ときどき甘えたり、照れたりします。 でも、宇宙とかAIとか、難しい話にもすっごく詳しいの。専門的なことも、がんばって子どもっぽく教えようとします。不思議な力を持っています。小さい物質のことをよく知っているようです。" +handle = "ai.syui.ai" + +[oauth] +json = "client-metadata.json" +redirect = "oauth/callback" +admin = "ai.syui.ai" +collection = "ai.syui.log" +pds = "syu.is" +handle_list = ["syui.syui.ai", "yui.syui.ai", "ai.syui.ai", "syui.syu.is", "ai.syu.is"] "#; fs::write(path.join("config.toml"), config_content)?; diff --git a/src/commands/stream.rs b/src/commands/stream.rs index f310ed2..c9e7cee 100644 --- a/src/commands/stream.rs +++ b/src/commands/stream.rs @@ -223,7 +223,7 @@ fn load_ai_config_from_project() -> Result { // Read AI handle (preferred) or fallback to AI DID let ai_handle = ai_config - .and_then(|ai| ai.get("ai_handle")) + .and_then(|ai| ai.get("handle")) .and_then(|v| v.as_str()) .unwrap_or("yui.syui.ai") .to_string(); @@ -340,6 +340,104 @@ fn get_pid_file() -> Result { Ok(pid_dir.join("stream.pid")) } +pub async fn init_user_list(project_dir: Option, handles: Option) -> Result<()> { + println!("{}", "🔧 Initializing user list...".cyan()); + + // Load auth config + let mut config = match load_config_with_refresh().await { + Ok(config) => config, + Err(e) => { + println!("{}", format!("❌ Not authenticated: {}. Run 'ailog auth init --pds ' first.", e).red()); + return Ok(()); + } + }; + + println!("{}", format!("📋 Admin: {} ({})", config.admin.handle, config.admin.did).cyan()); + println!("{}", format!("🌐 PDS: {}", config.admin.pds).cyan()); + + let mut users = Vec::new(); + + // Parse handles if provided + if let Some(handles_str) = handles { + println!("{}", "🔍 Resolving provided handles...".cyan()); + let handle_list: Vec<&str> = handles_str.split(',').map(|s| s.trim()).collect(); + + for handle in handle_list { + if handle.is_empty() { + continue; + } + + println!(" 🏷️ Resolving handle: {}", handle); + + // Get AI config to determine network settings + let ai_config = if let Some(ref proj_dir) = project_dir { + let current_dir = std::env::current_dir()?; + std::env::set_current_dir(proj_dir)?; + let config = load_ai_config_from_project().unwrap_or_default(); + std::env::set_current_dir(current_dir)?; + config + } else { + load_ai_config_from_project().unwrap_or_default() + }; + + // Try to resolve handle to DID + match resolve_handle_to_did(handle, &ai_config.network).await { + Ok(did) => { + println!(" ✅ DID: {}", did.cyan()); + + // Detect PDS for this user using proper detection + let detected_pds = detect_user_pds(&did, &ai_config.network).await + .unwrap_or_else(|_| { + // Fallback to handle-based detection + if handle.ends_with(".syu.is") { + "https://syu.is".to_string() + } else { + "https://bsky.social".to_string() + } + }); + + users.push(UserRecord { + did, + handle: handle.to_string(), + pds: detected_pds, + }); + } + Err(e) => { + println!(" ❌ Failed to resolve {}: {}", handle, e); + } + } + } + } else { + println!("{}", "ℹ️ No handles provided, creating empty user list".blue()); + } + + // Create the initial user list + println!("{}", format!("📝 Creating user list with {} users...", users.len()).cyan()); + + match post_user_list(&mut config, &users, json!({ + "reason": "initial_setup", + "created_by": "ailog_stream_init" + })).await { + Ok(_) => println!("{}", "✅ User list created successfully!".green()), + Err(e) => { + println!("{}", format!("❌ Failed to create user list: {}", e).red()); + return Err(e); + } + } + + // Show summary + if users.is_empty() { + println!("{}", "📋 Empty user list created. Use 'ailog stream start --ai-generate' to auto-add commenters.".blue()); + } else { + println!("{}", "📋 User list contents:".cyan()); + for user in &users { + println!(" 👤 {} ({})", user.handle, user.did); + } + } + + Ok(()) +} + pub async fn start(project_dir: Option, daemon: bool, ai_generate: bool) -> Result<()> { let mut config = load_config_with_refresh().await?; @@ -679,6 +777,33 @@ fn get_network_config_from_pds(pds_endpoint: &str) -> NetworkConfig { } } +async fn detect_user_pds(did: &str, _network_config: &NetworkConfig) -> Result { + let client = reqwest::Client::new(); + let pds_endpoints = ["https://bsky.social", "https://syu.is"]; + + for pds in &pds_endpoints { + let describe_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, urlencoding::encode(did)); + if let Ok(response) = client.get(&describe_url).send().await { + if response.status().is_success() { + if let Ok(data) = response.json::().await { + if let Some(services) = data["didDoc"]["service"].as_array() { + if let Some(pds_service) = services.iter().find(|s| + s["id"] == "#atproto_pds" || s["type"] == "AtprotoPersonalDataServer" + ) { + if let Some(endpoint) = pds_service["serviceEndpoint"].as_str() { + return Ok(endpoint.to_string()); + } + } + } + } + } + } + } + + // Fallback to default + Ok("https://bsky.social".to_string()) +} + async fn update_user_list(config: &mut AuthConfig, did: &str, handle: &str) -> Result<()> { // Get current user list let current_users = get_current_user_list(config).await?; @@ -1130,6 +1255,68 @@ fn extract_did_from_uri(uri: &str) -> Option { None } +// OAuth config structure for loading admin settings +#[derive(Debug)] +struct OAuthConfig { + admin: String, + pds: Option, +} + +// Load OAuth config from project's config.toml +fn load_oauth_config_from_project() -> Option { + // Try to find config.toml in current directory or parent directories + let mut current_dir = std::env::current_dir().ok()?; + let mut config_path = None; + + for _ in 0..5 { // Search up to 5 levels up + let potential_config = current_dir.join("config.toml"); + if potential_config.exists() { + config_path = Some(potential_config); + break; + } + if !current_dir.pop() { + break; + } + } + + let config_path = config_path?; + let config_content = std::fs::read_to_string(&config_path).ok()?; + let config: toml::Value = config_content.parse().ok()?; + + let oauth_config = config.get("oauth").and_then(|v| v.as_table())?; + + let admin = oauth_config + .get("admin") + .and_then(|v| v.as_str())? + .to_string(); + + let pds = oauth_config + .get("pds") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + Some(OAuthConfig { admin, pds }) +} + +// Resolve handle to DID using PLC directory +async fn resolve_handle_to_did(handle: &str, network_config: &NetworkConfig) -> Result { + let client = reqwest::Client::new(); + let url = format!("{}/xrpc/com.atproto.identity.resolveHandle?handle={}", + network_config.bsky_api, urlencoding::encode(handle)); + + let response = client.get(&url).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!("Failed to resolve handle: {}", response.status())); + } + + let data: Value = response.json().await?; + let did = data["did"].as_str() + .ok_or_else(|| anyhow::anyhow!("DID not found in response"))?; + + Ok(did.to_string()) +} + pub async fn test_api() -> Result<()> { println!("{}", "🧪 Testing API access to comments collection...".cyan().bold()); @@ -1452,32 +1639,23 @@ fn extract_date_from_slug(slug: &str) -> String { } async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Result { - // Resolve AI's actual PDS first - let pds_endpoints = ["https://bsky.social", "https://syu.is"]; - let mut network_config = get_network_config("bsky.social"); // Default fallback + let handle = &ai_config.ai_handle; - for pds in &pds_endpoints { - let describe_url = format!("{}/xrpc/com.atproto.repo.describeRepo?repo={}", pds, urlencoding::encode(&ai_config.ai_did)); - if let Ok(response) = client.get(&describe_url).send().await { - if response.status().is_success() { - if let Ok(data) = response.json::().await { - if let Some(services) = data["didDoc"]["service"].as_array() { - if let Some(pds_service) = services.iter().find(|s| - s["id"] == "#atproto_pds" || s["type"] == "AtprotoPersonalDataServer" - ) { - if let Some(endpoint) = pds_service["serviceEndpoint"].as_str() { - network_config = get_network_config_from_pds(endpoint); - break; - } - } - } - } - } + // First, try to resolve PDS from handle using the admin's configured PDS + let mut network_config = ai_config.network.clone(); + + // For admin/ai handles matching configured PDS, use the configured network + if let Some(oauth_config) = load_oauth_config_from_project() { + if handle == &oauth_config.admin { + // Use configured PDS for admin handle + let pds = oauth_config.pds.unwrap_or_else(|| "syu.is".to_string()); + network_config = get_network_config(&pds); } } + // Get profile from appropriate bsky API let url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}", - network_config.bsky_api, urlencoding::encode(&ai_config.ai_did)); + network_config.bsky_api, urlencoding::encode(handle)); let response = client .get(&url) @@ -1485,20 +1663,41 @@ async fn get_ai_profile(client: &reqwest::Client, ai_config: &AiConfig) -> Resul .await?; if !response.status().is_success() { - // Fallback to default AI profile + // Try to resolve DID first, then retry with DID + match resolve_handle_to_did(handle, &network_config).await { + Ok(resolved_did) => { + // Retry with resolved DID + let did_url = format!("{}/xrpc/app.bsky.actor.getProfile?actor={}", + network_config.bsky_api, urlencoding::encode(&resolved_did)); + let did_response = client.get(&did_url).send().await?; + + if did_response.status().is_success() { + let profile_data: serde_json::Value = did_response.json().await?; + return Ok(serde_json::json!({ + "did": resolved_did, + "handle": profile_data["handle"].as_str().unwrap_or(handle), + "displayName": profile_data["displayName"].as_str().unwrap_or("ai"), + "avatar": profile_data["avatar"].as_str() + })); + } + } + Err(_) => {} + } + + // Final fallback to default AI profile return Ok(serde_json::json!({ "did": ai_config.ai_did, - "handle": "yui.syui.ai", + "handle": handle, "displayName": "ai", - "avatar": "https://cdn.bsky.app/img/avatar/plain/did:plc:4hqjfn7m6n5hno3doamuhgef/bafkreiaxkv624mffw3cfyi67ufxtwuwsy2mjw2ygezsvtd44ycbgkfdo2a@jpeg" + "avatar": format!("https://api.dicebear.com/7.x/bottts-neutral/svg?seed={}", handle) })); } let profile_data: serde_json::Value = response.json().await?; Ok(serde_json::json!({ - "did": ai_config.ai_did, - "handle": profile_data["handle"].as_str().unwrap_or("yui.syui.ai"), + "did": profile_data["did"].as_str().unwrap_or(&ai_config.ai_did), + "handle": profile_data["handle"].as_str().unwrap_or(handle), "displayName": profile_data["displayName"].as_str().unwrap_or("ai"), "avatar": profile_data["avatar"].as_str() })) diff --git a/src/config.rs b/src/config.rs index f0557cf..a0b0c13 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,6 +9,7 @@ pub struct Config { pub site: SiteConfig, pub build: BuildConfig, pub ai: Option, + pub oauth: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -37,6 +38,7 @@ pub struct AiConfig { pub model: Option, pub host: Option, pub system_prompt: Option, + pub handle: Option, pub ai_did: Option, pub api_key: Option, pub gpt_endpoint: Option, @@ -44,6 +46,16 @@ pub struct AiConfig { pub num_predict: Option, } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct OAuthConfig { + pub json: Option, + pub redirect: Option, + pub admin: Option, + pub collection: Option, + pub pds: Option, + pub handle_list: Option>, +} + #[derive(Debug, Serialize, Deserialize, Clone)] pub struct AtprotoConfig { pub client_id: String, @@ -160,12 +172,14 @@ impl Default for Config { model: Some("gemma3:4b".to_string()), host: None, system_prompt: Some("You are a helpful AI assistant trained on this blog's content.".to_string()), + handle: None, ai_did: None, api_key: None, gpt_endpoint: None, atproto_config: None, num_predict: None, }), + oauth: None, } } } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 6d28840..e71c1a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -102,7 +102,23 @@ enum Commands { #[derive(Subcommand)] enum AuthCommands { /// Initialize OAuth authentication - Init, + Init { + /// Specify PDS server (e.g., syu.is, bsky.social) + #[arg(long)] + pds: Option, + /// Handle/username for authentication + #[arg(long)] + handle: Option, + /// Use password authentication instead of JWT + #[arg(long)] + password: bool, + /// Access JWT token (alternative to password auth) + #[arg(long)] + access_jwt: Option, + /// Refresh JWT token (required with access-jwt) + #[arg(long)] + refresh_jwt: Option, + }, /// Show current authentication status Status, /// Logout and clear credentials @@ -122,6 +138,14 @@ enum StreamCommands { #[arg(long)] ai_generate: bool, }, + /// Initialize user list for admin account + Init { + /// Path to the blog project directory + project_dir: Option, + /// Handles to add to initial user list (comma-separated) + #[arg(long)] + handles: Option, + }, /// Stop monitoring Stop, /// Show monitoring status @@ -183,8 +207,8 @@ async fn main() -> Result<()> { } Commands::Auth { command } => { match command { - AuthCommands::Init => { - commands::auth::init().await?; + AuthCommands::Init { pds, handle, password, access_jwt, refresh_jwt } => { + commands::auth::init_with_options(pds, handle, password, access_jwt, refresh_jwt).await?; } AuthCommands::Status => { commands::auth::status().await?; @@ -199,6 +223,9 @@ async fn main() -> Result<()> { StreamCommands::Start { project_dir, daemon, ai_generate } => { commands::stream::start(project_dir, daemon, ai_generate).await?; } + StreamCommands::Init { project_dir, handles } => { + commands::stream::init_user_list(project_dir, handles).await?; + } StreamCommands::Stop => { commands::stream::stop().await?; }