add 2fa
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::io::{self, Write};
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::oauth;
|
||||
@@ -6,20 +8,53 @@ use crate::lexicons::com_atproto_server;
|
||||
use crate::types::{CreateSessionRequest, CreateSessionResponse};
|
||||
use crate::xrpc::XrpcClient;
|
||||
|
||||
/// Login to ATProto PDS
|
||||
/// Login to ATProto PDS (with 2FA support)
|
||||
pub async fn login(handle: &str, password: &str, pds: &str, is_bot: bool) -> Result<()> {
|
||||
let client = XrpcClient::new(pds);
|
||||
|
||||
let req = CreateSessionRequest {
|
||||
identifier: handle.to_string(),
|
||||
password: password.to_string(),
|
||||
auth_factor_token: None,
|
||||
};
|
||||
|
||||
let account_type = if is_bot { "bot" } else { "user" };
|
||||
println!("Logging in to {} as {} ({})...", pds, handle, account_type);
|
||||
|
||||
let session_res: CreateSessionResponse =
|
||||
client.call_unauth(&com_atproto_server::CREATE_SESSION, &req).await?;
|
||||
let session_res = match client
|
||||
.call_unauth::<_, CreateSessionResponse>(&com_atproto_server::CREATE_SESSION, &req)
|
||||
.await
|
||||
{
|
||||
Ok(res) => res,
|
||||
Err(e) => {
|
||||
// Check if 2FA is required
|
||||
let err_str = e.to_string();
|
||||
if err_str.contains("AuthFactorTokenRequired") {
|
||||
eprintln!("2FA is enabled. Check your email for a confirmation code.");
|
||||
eprint!("Enter 2FA code: ");
|
||||
io::stderr().flush()?;
|
||||
let mut code = String::new();
|
||||
io::stdin().read_line(&mut code)?;
|
||||
let code = code.trim().to_string();
|
||||
|
||||
if code.is_empty() {
|
||||
anyhow::bail!("No code entered, aborting.");
|
||||
}
|
||||
|
||||
let req_2fa = CreateSessionRequest {
|
||||
identifier: handle.to_string(),
|
||||
password: password.to_string(),
|
||||
auth_factor_token: Some(code),
|
||||
};
|
||||
|
||||
client
|
||||
.call_unauth(&com_atproto_server::CREATE_SESSION, &req_2fa)
|
||||
.await?
|
||||
} else {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let session = Session {
|
||||
did: session_res.did,
|
||||
|
||||
@@ -14,3 +14,4 @@ pub mod pds;
|
||||
pub mod gpt;
|
||||
pub mod oauth;
|
||||
pub mod setup;
|
||||
pub mod twofa;
|
||||
|
||||
294
src/commands/twofa.rs
Normal file
294
src/commands/twofa.rs
Normal file
@@ -0,0 +1,294 @@
|
||||
use std::io::{self, Write};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use super::token;
|
||||
use crate::types::{
|
||||
GetSessionResponse, RequestEmailUpdateResponse, UpdateEmailRequest,
|
||||
};
|
||||
|
||||
/// Read a line from stdin with a prompt
|
||||
fn prompt_input(msg: &str) -> Result<String> {
|
||||
eprint!("{}", msg);
|
||||
io::stderr().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().read_line(&mut buf)?;
|
||||
Ok(buf.trim().to_string())
|
||||
}
|
||||
|
||||
/// Build XRPC URL
|
||||
fn xrpc_url(pds: &str, nsid: &str) -> String {
|
||||
format!("https://{}/xrpc/{}", pds, nsid)
|
||||
}
|
||||
|
||||
/// Bearer GET request (bypasses OAuth/DPoP entirely)
|
||||
async fn bearer_get<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
pds: &str,
|
||||
nsid: &str,
|
||||
token: &str,
|
||||
) -> Result<T> {
|
||||
let url = xrpc_url(pds, nsid);
|
||||
let res = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.context("XRPC GET failed")?;
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("XRPC error ({}): {}", status.as_u16(), body);
|
||||
}
|
||||
res.json::<T>().await.context("Failed to parse response")
|
||||
}
|
||||
|
||||
/// Bearer POST request without body
|
||||
async fn bearer_post_empty<T: serde::de::DeserializeOwned>(
|
||||
client: &reqwest::Client,
|
||||
pds: &str,
|
||||
nsid: &str,
|
||||
token: &str,
|
||||
) -> Result<T> {
|
||||
let url = xrpc_url(pds, nsid);
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.send()
|
||||
.await
|
||||
.context("XRPC POST failed")?;
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("XRPC error ({}): {}", status.as_u16(), body);
|
||||
}
|
||||
res.json::<T>().await.context("Failed to parse response")
|
||||
}
|
||||
|
||||
/// Bearer POST request with JSON body
|
||||
async fn bearer_post<B: serde::Serialize>(
|
||||
client: &reqwest::Client,
|
||||
pds: &str,
|
||||
nsid: &str,
|
||||
token: &str,
|
||||
body: &B,
|
||||
) -> Result<String> {
|
||||
let url = xrpc_url(pds, nsid);
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", token))
|
||||
.json(body)
|
||||
.send()
|
||||
.await
|
||||
.context("XRPC POST failed")?;
|
||||
let status = res.status();
|
||||
let response_body = res.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("XRPC error ({}): {}", status.as_u16(), response_body);
|
||||
}
|
||||
Ok(response_body)
|
||||
}
|
||||
|
||||
/// Refresh legacy session via Bearer token (no DPoP)
|
||||
async fn refresh_legacy(
|
||||
client: &reqwest::Client,
|
||||
pds: &str,
|
||||
refresh_jwt: &str,
|
||||
) -> Result<token::Session> {
|
||||
let url = xrpc_url(pds, "com.atproto.server.refreshSession");
|
||||
let res = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", refresh_jwt))
|
||||
.send()
|
||||
.await
|
||||
.context("Refresh session failed")?;
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Refresh failed ({}): {}", status.as_u16(), body);
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RefreshResponse {
|
||||
did: String,
|
||||
handle: String,
|
||||
access_jwt: String,
|
||||
refresh_jwt: String,
|
||||
}
|
||||
|
||||
let r: RefreshResponse = res.json().await.context("Failed to parse refresh response")?;
|
||||
Ok(token::Session {
|
||||
did: r.did,
|
||||
handle: r.handle,
|
||||
access_jwt: r.access_jwt,
|
||||
refresh_jwt: r.refresh_jwt,
|
||||
pds: Some(pds.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Load legacy session, refresh it, and return (session, http client, pds).
|
||||
/// All requests use plain Bearer auth, completely bypassing OAuth/DPoP.
|
||||
async fn get_legacy_session(is_bot: bool) -> Result<(token::Session, reqwest::Client, String)> {
|
||||
let session = if is_bot {
|
||||
token::load_bot_session()?
|
||||
} else {
|
||||
token::load_session()?
|
||||
};
|
||||
let pds = session.pds.as_deref().unwrap_or("bsky.social").to_string();
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let refreshed = refresh_legacy(&client, &pds, &session.refresh_jwt).await?;
|
||||
if is_bot {
|
||||
token::save_bot_session(&refreshed)?;
|
||||
} else {
|
||||
token::save_session(&refreshed)?;
|
||||
}
|
||||
|
||||
Ok((refreshed, client, pds))
|
||||
}
|
||||
|
||||
/// Resolve email: from --email flag, API response, or interactive prompt
|
||||
fn resolve_email(api_email: Option<String>, flag_email: Option<&str>) -> Result<String> {
|
||||
if let Some(e) = flag_email {
|
||||
return Ok(e.to_string());
|
||||
}
|
||||
if let Some(e) = api_email {
|
||||
return Ok(e);
|
||||
}
|
||||
prompt_input("Email not available from session. Enter your email: ")
|
||||
}
|
||||
|
||||
/// Show current 2FA status
|
||||
pub async fn status(is_bot: bool) -> Result<()> {
|
||||
let (session, client, pds) = get_legacy_session(is_bot).await?;
|
||||
|
||||
let info: GetSessionResponse = bearer_get(
|
||||
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
println!("Handle: {}", info.handle);
|
||||
println!("Email: {}", info.email.as_deref().unwrap_or("(not available)"));
|
||||
println!("2FA: {}", if info.email_auth_factor { "enabled" } else { "disabled" });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Enable email 2FA
|
||||
pub async fn enable(is_bot: bool, email_flag: Option<&str>) -> Result<()> {
|
||||
let (session, client, pds) = get_legacy_session(is_bot).await?;
|
||||
|
||||
let info: GetSessionResponse = bearer_get(
|
||||
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
if info.email_auth_factor {
|
||||
println!("2FA is already enabled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let email = resolve_email(info.email, email_flag)?;
|
||||
|
||||
println!("Requesting confirmation code to {}...", email);
|
||||
let resp: RequestEmailUpdateResponse = bearer_post_empty(
|
||||
&client, &pds, "com.atproto.server.requestEmailUpdate", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
if !resp.token_required {
|
||||
let req = UpdateEmailRequest {
|
||||
email,
|
||||
token: None,
|
||||
email_auth_factor: Some(true),
|
||||
};
|
||||
let res_body = bearer_post(
|
||||
&client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req,
|
||||
).await?;
|
||||
if !res_body.is_empty() {
|
||||
eprintln!("PDS response: {}", res_body);
|
||||
}
|
||||
} else {
|
||||
let token = prompt_input("Enter confirmation code from email: ")?;
|
||||
if token.is_empty() {
|
||||
anyhow::bail!("No code entered, aborting.");
|
||||
}
|
||||
|
||||
let req = UpdateEmailRequest {
|
||||
email,
|
||||
token: Some(token),
|
||||
email_auth_factor: Some(true),
|
||||
};
|
||||
|
||||
let res_body = bearer_post(
|
||||
&client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req,
|
||||
).await?;
|
||||
if !res_body.is_empty() {
|
||||
eprintln!("PDS response: {}", res_body);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify
|
||||
let check: GetSessionResponse = bearer_get(
|
||||
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
if check.email_auth_factor {
|
||||
println!("2FA enabled.");
|
||||
} else {
|
||||
println!("Warning: PDS accepted the request but 2FA is still disabled.");
|
||||
println!("This PDS may not support email 2FA via updateEmail.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disable email 2FA
|
||||
pub async fn disable(is_bot: bool, email_flag: Option<&str>) -> Result<()> {
|
||||
let (session, client, pds) = get_legacy_session(is_bot).await?;
|
||||
|
||||
let info: GetSessionResponse = bearer_get(
|
||||
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
if !info.email_auth_factor {
|
||||
println!("2FA is already disabled.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let email = resolve_email(info.email, email_flag)?;
|
||||
|
||||
println!("Requesting confirmation code to {}...", email);
|
||||
let _resp: RequestEmailUpdateResponse = bearer_post_empty(
|
||||
&client, &pds, "com.atproto.server.requestEmailUpdate", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
let token = prompt_input("Enter confirmation code from email: ")?;
|
||||
if token.is_empty() {
|
||||
anyhow::bail!("No code entered, aborting.");
|
||||
}
|
||||
|
||||
let req = UpdateEmailRequest {
|
||||
email,
|
||||
token: Some(token),
|
||||
email_auth_factor: Some(false),
|
||||
};
|
||||
|
||||
let res_body = bearer_post(
|
||||
&client, &pds, "com.atproto.server.updateEmail", &session.access_jwt, &req,
|
||||
).await?;
|
||||
if !res_body.is_empty() {
|
||||
eprintln!("PDS response: {}", res_body);
|
||||
}
|
||||
|
||||
// Verify
|
||||
let check: GetSessionResponse = bearer_get(
|
||||
&client, &pds, "com.atproto.server.getSession", &session.access_jwt,
|
||||
).await?;
|
||||
|
||||
if !check.email_auth_factor {
|
||||
println!("2FA disabled.");
|
||||
} else {
|
||||
println!("Warning: PDS accepted the request but 2FA is still enabled.");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
45
src/main.rs
45
src/main.rs
@@ -222,6 +222,38 @@ enum Commands {
|
||||
|
||||
/// Initialize config
|
||||
Setup,
|
||||
|
||||
/// Two-factor authentication (email 2FA)
|
||||
#[command(name = "2fa")]
|
||||
TwoFa {
|
||||
#[command(subcommand)]
|
||||
command: TwoFaCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TwoFaCommands {
|
||||
/// Show 2FA status
|
||||
Status {
|
||||
#[arg(long)]
|
||||
bot: bool,
|
||||
},
|
||||
/// Enable email 2FA
|
||||
Enable {
|
||||
#[arg(long)]
|
||||
bot: bool,
|
||||
/// Account email (prompted if not available from session)
|
||||
#[arg(short, long)]
|
||||
email: Option<String>,
|
||||
},
|
||||
/// Disable email 2FA
|
||||
Disable {
|
||||
#[arg(long)]
|
||||
bot: bool,
|
||||
/// Account email (prompted if not available from session)
|
||||
#[arg(short, long)]
|
||||
email: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -395,6 +427,19 @@ async fn main() -> Result<()> {
|
||||
Commands::Setup => {
|
||||
commands::setup::run()?;
|
||||
}
|
||||
Commands::TwoFa { command } => {
|
||||
match command {
|
||||
TwoFaCommands::Status { bot } => {
|
||||
commands::twofa::status(bot).await?;
|
||||
}
|
||||
TwoFaCommands::Enable { bot, email } => {
|
||||
commands::twofa::enable(bot, email.as_deref()).await?;
|
||||
}
|
||||
TwoFaCommands::Disable { bot, email } => {
|
||||
commands::twofa::disable(bot, email.as_deref()).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Commands::Gpt { command } => {
|
||||
match command {
|
||||
GptCommands::Core { download } => {
|
||||
|
||||
36
src/types.rs
36
src/types.rs
@@ -51,9 +51,45 @@ pub struct DescribeRepoResponse {
|
||||
|
||||
/// ATProto createSession request
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CreateSessionRequest {
|
||||
pub identifier: String,
|
||||
pub password: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth_factor_token: Option<String>,
|
||||
}
|
||||
|
||||
/// ATProto requestEmailUpdate response
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RequestEmailUpdateResponse {
|
||||
pub token_required: bool,
|
||||
}
|
||||
|
||||
/// ATProto updateEmail request
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UpdateEmailRequest {
|
||||
pub email: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub email_auth_factor: Option<bool>,
|
||||
}
|
||||
|
||||
/// ATProto getSession response
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GetSessionResponse {
|
||||
#[allow(dead_code)]
|
||||
pub did: String,
|
||||
pub handle: String,
|
||||
pub email: Option<String>,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub email_confirmed: bool,
|
||||
#[serde(default)]
|
||||
pub email_auth_factor: bool,
|
||||
}
|
||||
|
||||
/// ATProto createSession / refreshSession response
|
||||
|
||||
@@ -9,7 +9,10 @@ function getTranslatedContent(msg: ChatMessage): string {
|
||||
const translations = msg.value.translations
|
||||
|
||||
if (translations && currentLang !== originalLang && translations[currentLang]) {
|
||||
return translations[currentLang].content || msg.value.content.text
|
||||
const translated = translations[currentLang].content
|
||||
if (typeof translated === 'string') return translated
|
||||
if (translated && typeof translated === 'object' && 'text' in translated) return (translated as { text: string }).text
|
||||
return msg.value.content.text
|
||||
}
|
||||
return msg.value.content.text
|
||||
}
|
||||
@@ -36,8 +39,8 @@ function getRkeyFromUri(uri: string): string {
|
||||
return uri.split('/').pop() || ''
|
||||
}
|
||||
|
||||
// Extract DID from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx)
|
||||
function getDidFromUri(uri: string): string {
|
||||
// Extract authority from AT URI (at://did:plc:xxx/collection/rkey → did:plc:xxx)
|
||||
function getAuthorityFromUri(uri: string): string {
|
||||
return uri.replace('at://', '').split('/')[0]
|
||||
}
|
||||
|
||||
@@ -48,6 +51,13 @@ interface AuthorInfo {
|
||||
avatarUrl?: string
|
||||
}
|
||||
|
||||
// Handle/DID resolver: maps both handle and DID to DID
|
||||
let handleToDidMap = new Map<string, string>()
|
||||
|
||||
function resolveAuthorDid(authority: string): string {
|
||||
return handleToDidMap.get(authority) || authority
|
||||
}
|
||||
|
||||
// Build author info map
|
||||
function buildAuthorMap(
|
||||
userDid: string,
|
||||
@@ -58,6 +68,13 @@ function buildAuthorMap(
|
||||
botProfile?: Profile | null,
|
||||
pds?: string
|
||||
): Map<string, AuthorInfo> {
|
||||
// Build handle→DID mapping
|
||||
handleToDidMap = new Map<string, string>()
|
||||
handleToDidMap.set(userHandle, userDid)
|
||||
handleToDidMap.set(userDid, userDid)
|
||||
handleToDidMap.set(botHandle, botDid)
|
||||
handleToDidMap.set(botDid, botDid)
|
||||
|
||||
const authors = new Map<string, AuthorInfo>()
|
||||
|
||||
// User info
|
||||
@@ -79,6 +96,12 @@ function buildAuthorMap(
|
||||
return authors
|
||||
}
|
||||
|
||||
// Map collection name to chat type slug
|
||||
function chatTypeSlug(chatCollection: string): string {
|
||||
if (chatCollection === 'ai.syui.ue.chat') return 'ue'
|
||||
return 'log'
|
||||
}
|
||||
|
||||
// Render chat threads list (conversations this user started)
|
||||
export function renderChatThreadList(
|
||||
messages: ChatMessage[],
|
||||
@@ -88,7 +111,8 @@ export function renderChatThreadList(
|
||||
botHandle: string,
|
||||
userProfile?: Profile | null,
|
||||
botProfile?: Profile | null,
|
||||
pds?: string
|
||||
pds?: string,
|
||||
chatCollection: string = 'ai.syui.log.chat'
|
||||
): string {
|
||||
// Build set of all message URIs
|
||||
const allUris = new Set(messages.map(m => m.uri))
|
||||
@@ -101,7 +125,7 @@ export function renderChatThreadList(
|
||||
const rootMessages: ChatMessage[] = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (getDidFromUri(msg.uri) !== userDid) continue
|
||||
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||
|
||||
if (!msg.value.root) {
|
||||
// No root = explicit conversation start
|
||||
@@ -132,7 +156,7 @@ export function renderChatThreadList(
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = getDidFromUri(msg.uri)
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
@@ -147,13 +171,14 @@ export function renderChatThreadList(
|
||||
const preview = lines.join('\n')
|
||||
|
||||
return `
|
||||
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-thread-item">
|
||||
<a href="/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}" class="chat-thread-item">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-thread-content">
|
||||
<div class="chat-thread-header">
|
||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||
<span class="chat-type-badge" data-type="${chatTypeSlug(chatCollection)}">${chatTypeSlug(chatCollection)}</span>
|
||||
<span class="chat-time">${time}</span>
|
||||
</div>
|
||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||
@@ -217,7 +242,7 @@ export function renderChatThread(
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = getDidFromUri(msg.uri)
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
@@ -230,7 +255,7 @@ export function renderChatThread(
|
||||
const content = renderMarkdown(displayContent)
|
||||
const recordLink = `/@${author.handle}/at/collection/${chatCollection}/${rkey}`
|
||||
const canEdit = loggedInDid && authorDid === loggedInDid
|
||||
const editLink = `/@${userHandle}/at/chat/${rkey}/edit`
|
||||
const editLink = `/@${userHandle}/at/chat/${chatTypeSlug(chatCollection)}/${rkey}/edit`
|
||||
|
||||
return `
|
||||
<article class="chat-message">
|
||||
@@ -252,9 +277,15 @@ export function renderChatThread(
|
||||
return `<div class="chat-list">${items}</div>`
|
||||
}
|
||||
|
||||
// Render chat list page
|
||||
// Chat collection entry for unified list
|
||||
export interface ChatCollectionEntry {
|
||||
collection: string
|
||||
messages: ChatMessage[]
|
||||
}
|
||||
|
||||
// Render unified chat list page with filter buttons
|
||||
export function renderChatListPage(
|
||||
messages: ChatMessage[],
|
||||
collections: ChatCollectionEntry[],
|
||||
userDid: string,
|
||||
userHandle: string,
|
||||
botDid: string,
|
||||
@@ -263,8 +294,85 @@ export function renderChatListPage(
|
||||
botProfile?: Profile | null,
|
||||
pds?: string
|
||||
): string {
|
||||
const list = renderChatThreadList(messages, userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
return `<div class="chat-container">${list}</div>`
|
||||
// Merge all collections into a single list
|
||||
const allMessages: ChatMessage[] = []
|
||||
const messageCollectionMap = new Map<string, string>()
|
||||
|
||||
for (const c of collections) {
|
||||
for (const msg of c.messages) {
|
||||
allMessages.push(msg)
|
||||
messageCollectionMap.set(msg.uri, c.collection)
|
||||
}
|
||||
}
|
||||
|
||||
// Build author map with first collection's params (all share same users)
|
||||
const authors = buildAuthorMap(userDid, userHandle, botDid, botHandle, userProfile, botProfile, pds)
|
||||
|
||||
// Find root messages (same logic as renderChatThreadList)
|
||||
const allUris = new Set(allMessages.map(m => m.uri))
|
||||
const orphanedRootFirstMsg = new Map<string, ChatMessage>()
|
||||
const rootMessages: ChatMessage[] = []
|
||||
|
||||
for (const msg of allMessages) {
|
||||
if (resolveAuthorDid(getAuthorityFromUri(msg.uri)) !== userDid) continue
|
||||
|
||||
if (!msg.value.root) {
|
||||
rootMessages.push(msg)
|
||||
} else if (!allUris.has(msg.value.root)) {
|
||||
const existing = orphanedRootFirstMsg.get(msg.value.root)
|
||||
if (!existing || new Date(msg.value.publishedAt) < new Date(existing.value.publishedAt)) {
|
||||
orphanedRootFirstMsg.set(msg.value.root, msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const msg of orphanedRootFirstMsg.values()) {
|
||||
rootMessages.push(msg)
|
||||
}
|
||||
|
||||
if (rootMessages.length === 0) {
|
||||
return '<div class="chat-container"><p class="no-posts">No chat threads yet.</p></div>'
|
||||
}
|
||||
|
||||
// Sort by publishedAt (newest first)
|
||||
const sorted = [...rootMessages].sort((a, b) =>
|
||||
new Date(b.value.publishedAt).getTime() - new Date(a.value.publishedAt).getTime()
|
||||
)
|
||||
|
||||
const items = sorted.map(msg => {
|
||||
const authorDid = resolveAuthorDid(getAuthorityFromUri(msg.uri))
|
||||
const time = formatChatTime(msg.value.publishedAt)
|
||||
const rkey = getRkeyFromUri(msg.uri)
|
||||
const author = authors.get(authorDid) || { did: authorDid, handle: authorDid.slice(0, 20) + '...' }
|
||||
const collection = messageCollectionMap.get(msg.uri) || 'ai.syui.log.chat'
|
||||
const slug = chatTypeSlug(collection)
|
||||
|
||||
const avatarHtml = author.avatarUrl
|
||||
? `<img class="chat-avatar" src="${author.avatarUrl}" alt="@${escapeHtml(author.handle)}">`
|
||||
: `<div class="chat-avatar-placeholder"></div>`
|
||||
|
||||
const displayContent = getTranslatedContent(msg)
|
||||
const lines = displayContent.split('\n').slice(0, 3)
|
||||
const preview = lines.join('\n')
|
||||
|
||||
return `
|
||||
<a href="/@${userHandle}/at/chat/${slug}/${rkey}" class="chat-thread-item">
|
||||
<div class="chat-avatar-col">
|
||||
${avatarHtml}
|
||||
</div>
|
||||
<div class="chat-thread-content">
|
||||
<div class="chat-thread-header">
|
||||
<span class="chat-author">@${escapeHtml(author.handle)}</span>
|
||||
<span class="chat-type-badge" data-type="${slug}">${slug}</span>
|
||||
<span class="chat-time">${time}</span>
|
||||
</div>
|
||||
<div class="chat-thread-preview">${escapeHtml(preview)}</div>
|
||||
</div>
|
||||
</a>
|
||||
`
|
||||
}).join('')
|
||||
|
||||
return `<div class="chat-container"><div class="chat-thread-list">${items}</div></div>`
|
||||
}
|
||||
|
||||
// Render chat thread page
|
||||
@@ -307,7 +415,7 @@ export function renderChatEditForm(
|
||||
<div class="chat-edit-footer">
|
||||
<span class="chat-edit-collection">${collection}</span>
|
||||
<div class="chat-edit-buttons">
|
||||
<a href="/@${userHandle}/at/chat/${rkey}" class="chat-edit-cancel">Cancel</a>
|
||||
<a href="/@${userHandle}/at/chat/${chatTypeSlug(collection)}/${rkey}" class="chat-edit-cancel">Cancel</a>
|
||||
<button type="submit" class="chat-edit-save" id="chat-edit-save" data-rkey="${rkey}">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -81,16 +81,16 @@ export function parseRoute(): Route {
|
||||
return { type: 'vrm', handle: vrmMatch[1] }
|
||||
}
|
||||
|
||||
// Chat edit: /@handle/at/chat/{rkey}/edit
|
||||
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/edit$/)
|
||||
// Chat edit: /@handle/at/chat/{type}/{rkey}/edit
|
||||
const chatEditMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)\/edit$/)
|
||||
if (chatEditMatch) {
|
||||
return { type: 'chat-edit', handle: chatEditMatch[1], rkey: chatEditMatch[2] }
|
||||
return { type: 'chat-edit', handle: chatEditMatch[1], service: chatEditMatch[2], rkey: chatEditMatch[3] }
|
||||
}
|
||||
|
||||
// Chat thread: /@handle/at/chat/{rkey}
|
||||
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)$/)
|
||||
// Chat thread: /@handle/at/chat/{type}/{rkey}
|
||||
const chatThreadMatch = path.match(/^\/@([^/]+)\/at\/chat\/([^/]+)\/([^/]+)$/)
|
||||
if (chatThreadMatch) {
|
||||
return { type: 'chat-thread', handle: chatThreadMatch[1], rkey: chatThreadMatch[2] }
|
||||
return { type: 'chat-thread', handle: chatThreadMatch[1], service: chatThreadMatch[2], rkey: chatThreadMatch[3] }
|
||||
}
|
||||
|
||||
// Chat list: /@handle/at/chat
|
||||
@@ -133,10 +133,10 @@ export function navigate(route: Route): void {
|
||||
path = `/@${route.handle}/at/card-old`
|
||||
} else if (route.type === 'chat' && route.handle) {
|
||||
path = `/@${route.handle}/at/chat`
|
||||
} else if (route.type === 'chat-thread' && route.handle && route.rkey) {
|
||||
path = `/@${route.handle}/at/chat/${route.rkey}`
|
||||
} else if (route.type === 'chat-edit' && route.handle && route.rkey) {
|
||||
path = `/@${route.handle}/at/chat/${route.rkey}/edit`
|
||||
} else if (route.type === 'chat-thread' && route.handle && route.service && route.rkey) {
|
||||
path = `/@${route.handle}/at/chat/${route.service}/${route.rkey}`
|
||||
} else if (route.type === 'chat-edit' && route.handle && route.service && route.rkey) {
|
||||
path = `/@${route.handle}/at/chat/${route.service}/${route.rkey}/edit`
|
||||
} else if (route.type === 'link' && route.handle) {
|
||||
path = `/@${route.handle}/at/link`
|
||||
} else if (route.type === 'vrm' && route.handle) {
|
||||
|
||||
@@ -291,7 +291,7 @@ async function render(route: Route): Promise<void> {
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
|
||||
} else if (route.type === 'chat') {
|
||||
// Chat list page - show threads started by this user
|
||||
// Chat list page - show all chat collections
|
||||
if (!config.bot) {
|
||||
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
@@ -300,16 +300,23 @@ async function render(route: Route): Promise<void> {
|
||||
const botHandle = config.bot.handle
|
||||
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
||||
|
||||
// Load messages and profiles in parallel
|
||||
const [chatMessages, botProfile, pds] = await Promise.all([
|
||||
// Load all chat collections in parallel
|
||||
const [logMessages, ueMessages, botProfile, pds] = await Promise.all([
|
||||
getChatMessages(did, botDid, chatCollection),
|
||||
getChatMessages(did, botDid, 'ai.syui.ue.chat'),
|
||||
getProfile(botDid, false),
|
||||
getPds(did)
|
||||
])
|
||||
|
||||
// Collect available languages from chat messages
|
||||
const chatCollections = [
|
||||
{ collection: chatCollection, messages: logMessages },
|
||||
{ collection: 'ai.syui.ue.chat', messages: ueMessages },
|
||||
].filter(c => c.messages.length > 0)
|
||||
|
||||
// Collect available languages from all chat messages
|
||||
const allChatMsgs = [...logMessages, ...ueMessages]
|
||||
const chatLangs = new Set<string>()
|
||||
for (const msg of chatMessages) {
|
||||
for (const msg of allChatMsgs) {
|
||||
const msgLang = msg.value.langs?.[0] || 'ja'
|
||||
chatLangs.add(msgLang)
|
||||
if (msg.value.translations) {
|
||||
@@ -321,21 +328,20 @@ async function render(route: Route): Promise<void> {
|
||||
langList = Array.from(chatLangs)
|
||||
|
||||
html += renderLangSelector(langList)
|
||||
html += `<div id="content">${renderChatListPage(chatMessages, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
|
||||
html += `<div id="content">${renderChatListPage(chatCollections, did, handle, botDid, botHandle, profile, botProfile, pds || undefined)}</div>`
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
}
|
||||
|
||||
} else if (route.type === 'chat-thread' && route.rkey) {
|
||||
// Chat thread page - show full conversation
|
||||
} else if (route.type === 'chat-thread' && route.service && route.rkey) {
|
||||
// Chat thread page - route.service is 'log' or 'ue'
|
||||
if (!config.bot) {
|
||||
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
||||
html += `<nav class="back-nav"><a href="/@${handle}">${handle}</a></nav>`
|
||||
} else {
|
||||
const botDid = config.bot.did
|
||||
const botHandle = config.bot.handle
|
||||
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
||||
const chatCollection = route.service === 'ue' ? 'ai.syui.ue.chat' : (config.chatCollection || 'ai.syui.log.chat')
|
||||
|
||||
// Load messages and profiles in parallel
|
||||
const [chatMessages, botProfile, pds] = await Promise.all([
|
||||
getChatMessages(did, botDid, chatCollection),
|
||||
getProfile(botDid, false),
|
||||
@@ -360,7 +366,7 @@ async function render(route: Route): Promise<void> {
|
||||
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
||||
}
|
||||
|
||||
} else if (route.type === 'chat-edit' && route.rkey) {
|
||||
} else if (route.type === 'chat-edit' && route.service && route.rkey) {
|
||||
// Chat edit page
|
||||
if (!config.bot) {
|
||||
html += `<div id="content" class="error">Bot not configured in config.json</div>`
|
||||
@@ -370,9 +376,8 @@ async function render(route: Route): Promise<void> {
|
||||
html += `<nav class="back-nav"><a href="/@${handle}/at/chat">chat</a></nav>`
|
||||
} else {
|
||||
const botDid = config.bot.did
|
||||
const chatCollection = config.chatCollection || 'ai.syui.log.chat'
|
||||
const chatCollection = route.service === 'ue' ? 'ai.syui.ue.chat' : (config.chatCollection || 'ai.syui.log.chat')
|
||||
|
||||
// Get the specific message
|
||||
const chatMessages = await getChatMessages(did, botDid, chatCollection)
|
||||
const targetUri = `at://${did}/${chatCollection}/${route.rkey}`
|
||||
const message = chatMessages.find(m => m.uri === targetUri)
|
||||
@@ -464,6 +469,8 @@ async function render(route: Route): Promise<void> {
|
||||
setupVrmPage()
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Setup card migration button
|
||||
if (route.type === 'card-old' && cardMigrationState?.oldApiUser && cardMigrationState?.oldApiCards) {
|
||||
setupMigrationButton(
|
||||
|
||||
@@ -2576,6 +2576,35 @@ button.tab {
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat Type Badge */
|
||||
.chat-type-badge {
|
||||
font-size: 0.7em;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-type-badge[data-type="log"] {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.chat-type-badge[data-type="ue"] {
|
||||
background: rgba(168, 85, 247, 0.15);
|
||||
color: #a855f7;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.chat-type-badge[data-type="log"] {
|
||||
background: rgba(59, 130, 246, 0.25);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.chat-type-badge[data-type="ue"] {
|
||||
background: rgba(168, 85, 247, 0.25);
|
||||
color: #c084fc;
|
||||
}
|
||||
}
|
||||
|
||||
/* Chat Thread List */
|
||||
.chat-thread-list {
|
||||
display: flex;
|
||||
@@ -2612,6 +2641,11 @@ button.tab {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.chat-thread-header .chat-type-badge {
|
||||
order: 1;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.chat-thread-header .chat-author {
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
|
||||
Reference in New Issue
Block a user