This commit is contained in:
eric
2026-06-08 03:06:12 -05:00
parent 60dfe538b0
commit ebfec37e47
34 changed files with 1753 additions and 31 deletions

View File

@@ -44,5 +44,12 @@ PAYMENT_API_URL=http://127.0.0.1:8000
NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn NEXT_PUBLIC_MIROTALK_URL=https://mirotalk.nomadro.cn
NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn NEXT_PUBLIC_LOUNGE_URL=https://lounge.nomadro.cn
NEXT_PUBLIC_LISTMONK_URL=https://listmonk.nomadro.cn
LISTMONK_API_URL=https://listmonk.nomadro.cn
NEXT_PUBLIC_NTFY_URL=https://ntfy.nomadro.cn
NTFY_URL=https://ntfy.nomadro.cn
NTFY_ENABLED=true
# 本地调试可自动完成支付(无需真实回调) # 本地调试可自动完成支付(无需真实回调)
DEV_AUTO_PAY=true DEV_AUTO_PAY=true

View File

@@ -0,0 +1,243 @@
"use client";
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
import { Link } from "@/i18n/navigation";
import { useLocale, useTranslation } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import Footer from "@/app/components/Footer";
import { fetchAuthMe } from "@/app/lib/api-client";
import {
fetchConversation,
fetchMessages,
sendChatMessage,
type ChatMessage,
type ConversationItem,
} from "@/app/lib/chat";
import { mediaUrl } from "@/app/lib/media";
const POLL_MS = 4000;
function formatMessageTime(value?: string, locale = "zh"): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleTimeString(locale === "zh" ? "zh-CN" : "en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
export default function ChatThreadPage() {
const params = useParams();
const conversationId = String(params?.id || "");
const { t } = useTranslation("chat");
const locale = useLocale();
const [conversation, setConversation] = useState<ConversationItem | null>(null);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [draft, setDraft] = useState("");
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
const [error, setError] = useState("");
const [isLoggedIn, setIsLoggedIn] = useState(false);
const bottomRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);
const scrollToBottom = useCallback(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
const loadThread = useCallback(async () => {
if (!conversationId) return;
try {
const [meta, items] = await Promise.all([
fetchConversation(conversationId),
fetchMessages(conversationId),
]);
setConversation(meta);
setMessages(items);
setError("");
} catch (err) {
setError(err instanceof Error ? err.message : t("loadError"));
} finally {
setLoading(false);
}
}, [conversationId, t]);
useEffect(() => {
fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
}, []);
useEffect(() => {
loadThread();
}, [loadThread]);
useEffect(() => {
if (!conversationId || !isLoggedIn) return undefined;
const timer = window.setInterval(() => {
fetchMessages(conversationId)
.then((items) => setMessages(items))
.catch(() => undefined);
}, POLL_MS);
return () => window.clearInterval(timer);
}, [conversationId, isLoggedIn]);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const handleSend = async (event: FormEvent) => {
event.preventDefault();
const body = draft.trim();
if (!body || sending || !conversationId) return;
setSending(true);
setError("");
try {
const message = await sendChatMessage(conversationId, body);
setMessages((prev) => [...prev, message]);
setDraft("");
setConversation((prev) =>
prev
? {
...prev,
lastMessagePreview: body.replace(/\n/g, " ").slice(0, 180),
lastMessageAt: message.created || prev.lastMessageAt,
}
: prev
);
} catch (err) {
setError(err instanceof Error ? err.message : t("sendError"));
} finally {
setSending(false);
}
};
const peerName = conversation?.peer?.name || (locale === "zh" ? "成员" : "Member");
return (
<div className="flex min-h-screen flex-col bg-[#f6f7f9] dark:bg-gray-950">
<header className="sticky top-0 z-20 border-b border-gray-200 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95">
<div className="mx-auto flex max-w-3xl items-center gap-3 px-4 py-3 sm:px-6">
<Link
href="/chat"
className="rounded-full border border-gray-200 px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-900"
>
{t("back")}
</Link>
{conversation?.peer ? (
<div className="flex min-w-0 flex-1 items-center gap-3">
<img
src={mediaUrl(conversation.peer.photo)}
alt={peerName}
className="h-10 w-10 rounded-full object-cover"
/>
<div className="min-w-0">
<p className="truncate font-semibold text-gray-900 dark:text-gray-100">{peerName}</p>
{conversation.peer.location ? (
<p className="truncate text-xs text-gray-500 dark:text-gray-400">📍 {conversation.peer.location}</p>
) : null}
</div>
</div>
) : (
<p className="flex-1 font-semibold text-gray-900 dark:text-gray-100">{t("title")}</p>
)}
</div>
</header>
<main className="mx-auto flex w-full max-w-3xl flex-1 flex-col px-4 py-4 sm:px-6">
{!isLoggedIn ? (
<div className="flex flex-1 items-center justify-center rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<div>
<p className="text-gray-700 dark:text-gray-200">{t("loginRequired")}</p>
<Link href="/join" className="mt-4 inline-flex text-sm font-semibold text-[#ff4d4f] hover:underline">
{t("loginAction")}
</Link>
</div>
</div>
) : loading ? (
<div className="flex flex-1 items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-[#ff4d4f] border-t-transparent" />
</div>
) : error && !messages.length ? (
<div className="flex flex-1 items-center justify-center text-center">
<div>
<p className="text-gray-700 dark:text-gray-200">{error}</p>
<Link href="/chat" className="mt-3 inline-flex text-sm font-semibold text-[#ff4d4f] hover:underline">
{t("back")}
</Link>
</div>
</div>
) : (
<div
ref={listRef}
className="flex-1 space-y-3 overflow-y-auto rounded-2xl border border-gray-100 bg-white p-4 dark:border-gray-800 dark:bg-gray-900"
style={{ minHeight: "50vh" }}
>
{!messages.length ? (
<div className="flex h-full min-h-[40vh] flex-col items-center justify-center text-center text-sm text-gray-500 dark:text-gray-400">
<p>{t("emptyThread")}</p>
</div>
) : (
messages.map((message) => (
<div
key={message.id}
className={`flex ${message.mine ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[85%] rounded-2xl px-4 py-2.5 text-sm shadow-sm ${
message.mine
? "rounded-br-md bg-[#ff4d4f] text-white"
: "rounded-bl-md bg-gray-100 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
}`}
>
<p className="whitespace-pre-wrap break-words">{message.body}</p>
<p
className={`mt-1 text-[10px] ${
message.mine ? "text-white/70" : "text-gray-400 dark:text-gray-500"
}`}
>
{formatMessageTime(message.created, locale)}
</p>
</div>
</div>
))
)}
<div ref={bottomRef} />
</div>
)}
{isLoggedIn && !loading ? (
<form onSubmit={handleSend} className="sticky bottom-0 mt-4 pb-4">
{error && messages.length ? (
<p className="mb-2 text-center text-xs text-red-500">{error}</p>
) : null}
<div className="flex gap-2 rounded-2xl border border-gray-200 bg-white p-2 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<textarea
value={draft}
onChange={(event) => setDraft(event.target.value)}
rows={1}
maxLength={2000}
placeholder={t("inputPlaceholder")}
className="max-h-32 min-h-[44px] flex-1 resize-none bg-transparent px-3 py-2.5 text-sm text-gray-900 outline-none dark:text-gray-100"
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
void handleSend(event as unknown as FormEvent);
}
}}
/>
<button
type="submit"
disabled={!draft.trim() || sending}
className="self-end rounded-xl bg-[#ff4d4f] px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-red-600 disabled:cursor-not-allowed disabled:opacity-50"
>
{sending ? t("sending") : t("send")}
</button>
</div>
</form>
) : null}
</main>
<Footer />
</div>
);
}

159
app/[locale]/chat/page.tsx Normal file
View File

@@ -0,0 +1,159 @@
"use client";
import { useEffect, useState } from "react";
import { Link } from "@/i18n/navigation";
import { useLocale, useTranslation } from "@/i18n/navigation";
import Footer from "@/app/components/Footer";
import { fetchAuthMe } from "@/app/lib/api-client";
import { fetchConversations, type ConversationItem } from "@/app/lib/chat";
import { mediaUrl } from "@/app/lib/media";
function formatTime(value?: string, locale = "zh"): string {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return locale === "zh" ? "刚刚" : "Now";
if (minutes < 60) return locale === "zh" ? `${minutes} 分钟前` : `${minutes}m`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return locale === "zh" ? `${hours} 小时前` : `${hours}h`;
const days = Math.floor(hours / 24);
if (days < 7) return locale === "zh" ? `${days} 天前` : `${days}d`;
return `${date.getMonth() + 1}/${date.getDate()}`;
}
function ConversationRow({ item, locale }: { item: ConversationItem; locale: string }) {
const peerName = item.peer?.name || (locale === "zh" ? "成员" : "Member");
const preview = item.lastMessagePreview || (locale === "zh" ? "开始聊天吧" : "Say hello");
const unread = Number(item.unreadCount || 0);
return (
<Link
href={`/chat/${item.id}`}
className="flex items-center gap-3 rounded-2xl border border-gray-100 bg-white p-4 transition hover:border-[#ff4d4f]/30 hover:shadow-md dark:border-gray-800 dark:bg-gray-900 dark:hover:border-[#ff4d4f]/40"
>
<div className="relative flex-shrink-0">
<img
src={mediaUrl(item.peer?.photo)}
alt={peerName}
className="h-14 w-14 rounded-full object-cover shadow-sm"
/>
{unread > 0 ? (
<span className="absolute -right-1 -top-1 flex h-5 min-w-5 items-center justify-center rounded-full bg-[#ff4d4f] px-1 text-[10px] font-bold text-white">
{unread > 9 ? "9+" : unread}
</span>
) : null}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<p className="truncate font-semibold text-gray-900 dark:text-gray-100">{peerName}</p>
<span className="flex-shrink-0 text-[11px] text-gray-400">{formatTime(item.lastMessageAt, locale)}</span>
</div>
<p className="mt-1 truncate text-sm text-gray-500 dark:text-gray-400">
{item.peer?.location ? `📍 ${item.peer.location} · ` : ""}
{preview}
</p>
</div>
</Link>
);
}
export default function ChatListPage() {
const { t } = useTranslation("chat");
const locale = useLocale();
const [items, setItems] = useState<ConversationItem[]>([]);
const [unread, setUnread] = useState(0);
const [loading, setLoading] = useState(true);
const [isLoggedIn, setIsLoggedIn] = useState(false);
const load = () => {
setLoading(true);
fetchConversations()
.then((data) => {
setItems(data.items);
setUnread(data.unread);
})
.catch(() => {
setItems([]);
setUnread(0);
})
.finally(() => setLoading(false));
};
useEffect(() => {
fetchAuthMe().then((data) => setIsLoggedIn(Boolean(data?.user?.id)));
load();
}, []);
return (
<div className="min-h-screen bg-[#f6f7f9] dark:bg-gray-950">
<main className="mx-auto max-w-3xl px-4 py-8 sm:px-6 sm:py-10">
<header className="mb-6">
<p className="text-sm font-semibold text-[#ff4d4f]">Direct Messages</p>
<div className="mt-2 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-3xl font-bold text-gray-950 dark:text-gray-50">{t("title")}</h1>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("subtitle")}</p>
</div>
<div className="flex gap-2">
<Link
href="/messages"
className="rounded-xl border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200"
>
{t("notifications")}
</Link>
<Link
href="/dating"
className="rounded-xl bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white hover:bg-red-600"
>
{t("goMatching")}
</Link>
</div>
</div>
{unread > 0 ? (
<p className="mt-3 text-sm font-medium text-[#ff4d4f]">
{locale === "zh" ? `${unread} 条未读私信` : `${unread} unread message${unread === 1 ? "" : "s"}`}
</p>
) : null}
</header>
{!isLoggedIn ? (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-gray-700 dark:text-gray-200">{t("loginRequired")}</p>
<Link
href="/join"
className="mt-4 inline-flex rounded-full bg-[#ff4d4f] px-5 py-2.5 text-sm font-semibold text-white hover:bg-red-600"
>
{t("loginAction")}
</Link>
</div>
) : loading ? (
<div className="space-y-3">
{[1, 2, 3].map((key) => (
<div key={key} className="h-20 animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800" />
))}
</div>
) : items.length ? (
<div className="space-y-3">
{items.map((item) => (
<ConversationRow key={item.id} item={item} locale={locale} />
))}
</div>
) : (
<div className="rounded-2xl border border-dashed border-gray-200 bg-white p-8 text-center dark:border-gray-800 dark:bg-gray-900">
<p className="text-lg font-semibold text-gray-900 dark:text-gray-100">{t("emptyTitle")}</p>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">{t("emptySubtitle")}</p>
<Link
href="/dating"
className="mt-5 inline-flex rounded-full bg-[#ff4d4f] px-5 py-2.5 text-sm font-semibold text-white hover:bg-red-600"
>
{t("goMatching")}
</Link>
</div>
)}
</main>
<Footer />
</div>
);
}

View File

@@ -23,12 +23,10 @@ function ProfileRow({
badge?: string; badge?: string;
onSelect?: () => void; onSelect?: () => void;
}) { }) {
return ( const className =
<button "flex w-full items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 text-left shadow-sm transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-900";
type="button" const content = (
onClick={onSelect} <>
className="flex w-full items-center gap-3 rounded-xl border border-gray-100 bg-white p-3 text-left shadow-sm transition-shadow hover:shadow-md dark:border-gray-800 dark:bg-gray-900"
>
<img <img
src={mediaUrl(profile.photo)} src={mediaUrl(profile.photo)}
alt={profile.name} alt={profile.name}
@@ -46,8 +44,16 @@ function ProfileRow({
<span className="flex-shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[10px] text-gray-400 dark:bg-gray-800 dark:text-gray-500"> <span className="flex-shrink-0 rounded-full bg-gray-50 px-2 py-1 text-[10px] text-gray-400 dark:bg-gray-800 dark:text-gray-500">
{timeLabel} {timeLabel}
</span> </span>
</button> </>
); );
if (onSelect) {
return (
<button type="button" onClick={onSelect} className={className}>
{content}
</button>
);
}
return <div className={className}>{content}</div>;
} }
export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) { export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfile }: LikesPanelProps) {
@@ -93,15 +99,29 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{mutual.length ? ( {mutual.length ? (
mutual.map((profile) => ( mutual.map((profile) =>
<ProfileRow profile.conversationId ? (
key={profile.id} <Link
profile={profile} key={profile.id}
timeLabel={formatTime(profile.matchedAt)} href={`/chat/${profile.conversationId}`}
badge={t("mutualBadge")} className="block"
onSelect={() => onSelectProfile?.(profile)} >
/> <ProfileRow
)) profile={profile}
timeLabel={formatTime(profile.matchedAt)}
badge={t("mutualBadge")}
/>
</Link>
) : (
<ProfileRow
key={profile.id}
profile={profile}
timeLabel={formatTime(profile.matchedAt)}
badge={t("mutualBadge")}
onSelect={() => onSelectProfile?.(profile)}
/>
)
)
) : ( ) : (
<div className="rounded-xl border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400"> <div className="rounded-xl border border-dashed border-gray-200 p-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{t("noMutualYet")} {t("noMutualYet")}
@@ -110,7 +130,7 @@ export default function LikesPanel({ likes, mutual, formatTime, t, onSelectProfi
</div> </div>
{mutual.length > 0 ? ( {mutual.length > 0 ? (
<Link <Link
href="/messages" href="/chat"
className="mt-3 inline-flex w-full items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600" className="mt-3 inline-flex w-full items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-2 text-sm font-semibold text-white transition hover:bg-red-600"
> >
{t("goMessages")} {t("goMessages")}

View File

@@ -6,11 +6,12 @@ import type { DatingProfile } from "../types";
interface MatchModalProps { interface MatchModalProps {
profile: DatingProfile | null; profile: DatingProfile | null;
conversationId?: string;
onClose: () => void; onClose: () => void;
t: (key: string) => string; t: (key: string) => string;
} }
export default function MatchModal({ profile, onClose, t }: MatchModalProps) { export default function MatchModal({ profile, conversationId, onClose, t }: MatchModalProps) {
if (!profile) return null; if (!profile) return null;
return ( return (
@@ -33,7 +34,7 @@ export default function MatchModal({ profile, onClose, t }: MatchModalProps) {
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">📍 {profile.location}</p> <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">📍 {profile.location}</p>
<div className="mt-6 flex flex-col gap-3"> <div className="mt-6 flex flex-col gap-3">
<Link <Link
href="/messages" href={conversationId ? `/chat/${conversationId}` : "/chat"}
onClick={onClose} onClick={onClose}
className="inline-flex items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-3 text-sm font-semibold text-white transition hover:bg-red-600" className="inline-flex items-center justify-center rounded-full bg-[#ff4d4f] px-4 py-3 text-sm font-semibold text-white transition hover:bg-red-600"
> >

View File

@@ -63,7 +63,10 @@ function DatingPageInner() {
const [showLoginPrompt, setShowLoginPrompt] = useState(false); const [showLoginPrompt, setShowLoginPrompt] = useState(false);
const [showQuotaPrompt, setShowQuotaPrompt] = useState(false); const [showQuotaPrompt, setShowQuotaPrompt] = useState(false);
const [canUndo, setCanUndo] = useState(false); const [canUndo, setCanUndo] = useState(false);
const [matchCelebration, setMatchCelebration] = useState<DatingProfile | null>(null); const [matchCelebration, setMatchCelebration] = useState<{
profile: DatingProfile;
conversationId?: string;
} | null>(null);
const [mobilePanelOpen, setMobilePanelOpen] = useState(false); const [mobilePanelOpen, setMobilePanelOpen] = useState(false);
const [dragX, setDragX] = useState(0); const [dragX, setDragX] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@@ -193,7 +196,10 @@ function DatingPageInner() {
const result = await saveSwipe(currentProfile.id, action, activeTab); const result = await saveSwipe(currentProfile.id, action, activeTab);
setCanUndo(true); setCanUndo(true);
if (result.matched) { if (result.matched) {
setMatchCelebration(currentProfile); setMatchCelebration({
profile: currentProfile,
conversationId: result.match?.conversationId,
});
refreshSidePanels(); refreshSidePanels();
} else if (action === "like") { } else if (action === "like") {
refreshSidePanels(); refreshSidePanels();
@@ -467,7 +473,12 @@ function DatingPageInner() {
</div> </div>
) : null} ) : null}
<MatchModal profile={matchCelebration} onClose={() => setMatchCelebration(null)} t={t} /> <MatchModal
profile={matchCelebration?.profile ?? null}
conversationId={matchCelebration?.conversationId}
onClose={() => setMatchCelebration(null)}
t={t}
/>
<Footer /> <Footer />
</div> </div>
); );

View File

@@ -16,6 +16,7 @@ export interface ProfileRecord {
matchedAt?: string; matchedAt?: string;
intent?: MatchIntent; intent?: MatchIntent;
connectionId?: string; connectionId?: string;
conversationId?: string;
} }
export interface DatingProfile { export interface DatingProfile {
@@ -36,10 +37,16 @@ export interface DatingProfile {
likedAt?: string; likedAt?: string;
matchedAt?: string; matchedAt?: string;
intent?: MatchIntent; intent?: MatchIntent;
connectionId?: string;
conversationId?: string;
} }
export interface SwipeResult { export interface SwipeResult {
ok: boolean; ok: boolean;
matched: boolean; matched: boolean;
match?: Record<string, unknown> | null; match?: {
id?: string;
conversationId?: string;
[key: string]: unknown;
} | null;
} }

View File

@@ -24,6 +24,8 @@ export function mapProfile(record: ProfileRecord, index: number): DatingProfile
likedAt: record.likedAt, likedAt: record.likedAt,
matchedAt: record.matchedAt, matchedAt: record.matchedAt,
intent: record.intent, intent: record.intent,
connectionId: record.connectionId,
conversationId: record.conversationId,
}; };
} }

View File

@@ -175,6 +175,12 @@ export default function MessagesPage() {
</p> </p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link
href="/chat"
className="rounded-xl border border-[#ff4d4f]/30 bg-[#fff5f5] px-4 py-2 text-sm font-medium text-[#ff4d4f] hover:bg-[#ffecec] dark:border-[#ff4d4f]/40 dark:bg-[#ff4d4f]/10 dark:hover:bg-[#ff4d4f]/20"
>
</Link>
<button <button
type="button" type="button"
onClick={markAllRead} onClick={markAllRead}

View File

@@ -59,6 +59,7 @@ export default memo(function SiteMenu({
titleKey: "community", titleKey: "community",
items: [ items: [
{ labelKey: "messages", href: "/messages", icon: "🔔", new: true }, { labelKey: "messages", href: "/messages", icon: "🔔", new: true },
{ labelKey: "privateChat", href: "/chat", icon: "💬" },
{ labelKey: "chat", href: "/join", icon: "💬" }, { labelKey: "chat", href: "/join", icon: "💬" },
{ labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true }, { labelKey: "hostMeetup", href: "/meetups/host", icon: "🎤", new: true },
{ labelKey: "membersMap", href: "/map", icon: "🌐" }, { labelKey: "membersMap", href: "/map", icon: "🌐" },

81
app/lib/chat.ts Normal file
View File

@@ -0,0 +1,81 @@
import { apiFetch } from "@/app/lib/api-client";
export interface ChatPeer {
id: string;
name?: string;
photo?: string;
location?: string;
citySlug?: string;
}
export interface ConversationItem {
id: string;
type: string;
intent?: string;
pairKey?: string;
matchConnectionId?: string;
lastMessageAt?: string;
lastMessagePreview?: string;
unreadCount: number;
peer?: ChatPeer | null;
created?: string;
updated?: string;
}
export interface ChatMessage {
id: string;
conversationId?: string;
senderId: string;
body: string;
created?: string;
mine: boolean;
}
export async function fetchConversations(): Promise<{
items: ConversationItem[];
unread: number;
}> {
const data = await apiFetch<{ items: ConversationItem[]; unread: number }>("/api/conversations");
return {
items: data.items || [],
unread: Number(data.unread || 0),
};
}
export async function fetchConversation(conversationId: string): Promise<ConversationItem> {
const data = await apiFetch<{ conversation: ConversationItem }>(`/api/conversations/${conversationId}`);
return data.conversation;
}
export async function fetchConversationByConnection(connectionId: string): Promise<ConversationItem> {
const data = await apiFetch<{ conversation: ConversationItem }>(
`/api/conversations/by-connection/${connectionId}`
);
return data.conversation;
}
export async function fetchMessages(
conversationId: string,
options?: { limit?: number; before?: string }
): Promise<ChatMessage[]> {
const params = new URLSearchParams();
if (options?.limit) params.set("limit", String(options.limit));
if (options?.before) params.set("before", options.before);
const query = params.toString();
const data = await apiFetch<{ items: ChatMessage[] }>(
`/api/conversations/${conversationId}/messages${query ? `?${query}` : ""}`
);
return data.items || [];
}
export async function sendChatMessage(conversationId: string, body: string): Promise<ChatMessage> {
const data = await apiFetch<{ message: ChatMessage }>(`/api/conversations/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify({ body }),
});
return data.message;
}
export async function markChatRead(conversationId: string): Promise<void> {
await apiFetch(`/api/conversations/${conversationId}/read`, { method: "POST", body: JSON.stringify({}) });
}

228
backend/dm.py Normal file
View File

@@ -0,0 +1,228 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from .pb_client import pb, pb_quote
MAX_MESSAGE_LENGTH = 2000
def utc_now() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S.000Z")
def pair_key_for_users(user_a_id: str, user_b_id: str) -> str:
return "|".join(sorted([user_a_id, user_b_id]))
def ordered_user_ids(user_a_id: str, user_b_id: str) -> tuple[str, str]:
return tuple(sorted([user_a_id, user_b_id])) # type: ignore[return-value]
def public_record(record: dict[str, Any]) -> dict[str, Any]:
return {k: v for k, v in record.items() if not k.startswith("@")}
def conversation_participants(conversation: dict[str, Any]) -> set[str]:
return {str(conversation.get("userAId") or ""), str(conversation.get("userBId") or "")}
def other_participant_id(conversation: dict[str, Any], user_id: str) -> str | None:
participants = conversation_participants(conversation)
participants.discard("")
participants.discard(user_id)
return next(iter(participants), None)
def read_state_for(conversation: dict[str, Any]) -> dict[str, str]:
raw = conversation.get("readState")
if isinstance(raw, dict):
return {str(key): str(value) for key, value in raw.items() if value}
return {}
async def ensure_conversation_for_match(
*,
user_a_id: str,
user_b_id: str,
match_connection_id: str = "",
intent: str = "friends",
) -> dict[str, Any]:
pair_key = pair_key_for_users(user_a_id, user_b_id)
existing = await pb.first_record("conversations", filter=f'pairKey={pb_quote(pair_key)}')
if existing:
updates: dict[str, Any] = {}
if match_connection_id and not existing.get("matchConnectionId"):
updates["matchConnectionId"] = match_connection_id
if intent and not existing.get("intent"):
updates["intent"] = intent
if updates:
return await pb.update_record("conversations", existing["id"], updates)
return existing
user_a, user_b = ordered_user_ids(user_a_id, user_b_id)
return await pb.create_record(
"conversations",
{
"pairKey": pair_key,
"type": "match",
"userAId": user_a,
"userBId": user_b,
"matchConnectionId": match_connection_id,
"intent": intent,
"lastMessageAt": "",
"lastMessagePreview": "",
"readState": {},
},
)
async def sync_connection_conversation(connection: dict[str, Any]) -> dict[str, Any]:
conversation = await ensure_conversation_for_match(
user_a_id=str(connection.get("userAId") or ""),
user_b_id=str(connection.get("userBId") or ""),
match_connection_id=str(connection.get("id") or ""),
intent=str(connection.get("intent") or "friends"),
)
connection_id = str(connection.get("id") or "")
conversation_id = str(conversation.get("id") or "")
if connection_id and conversation_id and str(connection.get("conversationId") or "") != conversation_id:
connection = await pb.update_record(
"match_connections",
connection_id,
{"conversationId": conversation_id},
)
if connection_id and str(conversation.get("matchConnectionId") or "") != connection_id:
conversation = await pb.update_record(
"conversations",
conversation_id,
{"matchConnectionId": connection_id},
)
return conversation
async def backfill_conversations_for_user(user_id: str) -> None:
data = await pb.list_records(
"match_connections",
filter=f'userAId={pb_quote(user_id)} || userBId={pb_quote(user_id)}',
per_page=100,
sort="-created",
)
for connection in data.get("items", []):
await sync_connection_conversation(connection)
async def conversation_for_user(conversation_id: str, user_id: str) -> dict[str, Any] | None:
conversation = await pb.first_record("conversations", filter=f'id={pb_quote(conversation_id)}')
if not conversation:
return None
if user_id not in conversation_participants(conversation):
return None
return conversation
async def conversation_by_connection(connection_id: str, user_id: str) -> dict[str, Any] | None:
connection = await pb.first_record("match_connections", filter=f'id={pb_quote(connection_id)}')
if not connection:
return None
if user_id not in {str(connection.get("userAId") or ""), str(connection.get("userBId") or "")}:
return None
existing_id = str(connection.get("conversationId") or "")
if existing_id:
conversation = await conversation_for_user(existing_id, user_id)
if conversation:
return conversation
return await sync_connection_conversation(connection)
def unread_count_for(conversation: dict[str, Any], user_id: str) -> int:
last_read = read_state_for(conversation).get(user_id, "")
preview = str(conversation.get("lastMessagePreview") or "").strip()
if not preview:
return 0
last_message_at = str(conversation.get("lastMessageAt") or "")
if not last_message_at:
return 0
if not last_read:
return 1
return 1 if last_message_at > last_read else 0
async def list_conversations_for_user(user_id: str) -> list[dict[str, Any]]:
await backfill_conversations_for_user(user_id)
data = await pb.list_records(
"conversations",
filter=f'userAId={pb_quote(user_id)} || userBId={pb_quote(user_id)}',
per_page=100,
sort="-lastMessageAt,-updated,-created",
)
return list(data.get("items", []))
async def list_messages(conversation_id: str, *, limit: int = 50, before: str = "") -> list[dict[str, Any]]:
safe_limit = max(1, min(limit, 100))
filter_parts = [f'conversationId={pb_quote(conversation_id)}']
if before:
filter_parts.append(f'created<{pb_quote(before)}')
data = await pb.list_records(
"messages",
filter=" && ".join(filter_parts),
per_page=safe_limit,
sort="-created",
)
items = list(data.get("items", []))
items.reverse()
return items
async def mark_conversation_read(conversation: dict[str, Any], user_id: str) -> dict[str, Any]:
read_state = read_state_for(conversation)
read_state[user_id] = utc_now()
return await pb.update_record(
"conversations",
conversation["id"],
{"readState": read_state},
)
async def send_message(
*,
conversation: dict[str, Any],
sender_id: str,
body: str,
) -> tuple[dict[str, Any], str | None]:
text = body.strip()
if not text:
raise ValueError("消息不能为空")
if len(text) > MAX_MESSAGE_LENGTH:
raise ValueError(f"消息不能超过 {MAX_MESSAGE_LENGTH}")
if sender_id not in conversation_participants(conversation):
raise ValueError("无权发送消息")
preview = text.replace("\n", " ")
if len(preview) > 180:
preview = f"{preview[:177]}..."
message = await pb.create_record(
"messages",
{
"conversationId": conversation["id"],
"senderId": sender_id,
"body": text,
},
)
message_at = str(message.get("created") or utc_now())
read_state = read_state_for(conversation)
read_state[sender_id] = message_at
await pb.update_record(
"conversations",
conversation["id"],
{
"lastMessageAt": message_at,
"lastMessagePreview": preview,
"readState": read_state,
},
)
recipient_id = other_participant_id(conversation, sender_id)
return message, recipient_id

View File

@@ -362,8 +362,25 @@ COLLECTIONS: dict[str, list[dict[str, Any]]] = {
text("profileAId", max=80), text("profileAId", max=80),
text("profileBId", max=80), text("profileBId", max=80),
text("intent", max=40), text("intent", max=40),
text("conversationId", max=80),
date("matchedAt"), date("matchedAt"),
], ],
"conversations": [
text("pairKey", max=160),
text("type", max=40),
text("userAId", max=80),
text("userBId", max=80),
text("matchConnectionId", max=80),
text("intent", max=40),
text("lastMessagePreview", max=200),
date("lastMessageAt"),
json_field("readState"),
],
"messages": [
text("conversationId", required=True, max=80),
text("senderId", required=True, max=80),
text("body", required=True, max=2000),
],
"content_items": [ "content_items": [
text("slug", required=True, max=120), text("slug", required=True, max=120),
text("type", required=True, max=40), text("type", required=True, max=40),

View File

@@ -22,6 +22,20 @@ from pydantic import BaseModel, Field
from .avatar_assets import avatar_for_name from .avatar_assets import avatar_for_name
from .integrations import subscribe_newsletter from .integrations import subscribe_newsletter
from .ntfy_push import dispatch_ntfy_for_user, ntfy_subscribe_url, ntfy_topic_for_user from .ntfy_push import dispatch_ntfy_for_user, ntfy_subscribe_url, ntfy_topic_for_user
from .dm import (
MAX_MESSAGE_LENGTH,
conversation_by_connection,
conversation_for_user,
ensure_conversation_for_match,
list_conversations_for_user,
list_messages,
mark_conversation_read,
other_participant_id,
public_record as dm_public_record,
send_message as send_dm_message,
sync_connection_conversation,
unread_count_for,
)
from .meetup_live import ( from .meetup_live import (
access_lock_reason, access_lock_reason,
build_lounge_chat_url, build_lounge_chat_url,
@@ -131,6 +145,10 @@ class SwipePayload(BaseModel):
intent: str = "friends" intent: str = "friends"
class ChatMessagePayload(BaseModel):
body: str = Field(..., min_length=1, max_length=MAX_MESSAGE_LENGTH)
class DiscussionPayload(BaseModel): class DiscussionPayload(BaseModel):
title: str title: str
author: str = "NomadCNA" author: str = "NomadCNA"
@@ -431,8 +449,15 @@ async def ensure_match_connection(
pair_key = "|".join(sorted([user_a_id, user_b_id])) pair_key = "|".join(sorted([user_a_id, user_b_id]))
existing = await pb.first_record("match_connections", filter=f'pairKey={pb_quote(pair_key)}') existing = await pb.first_record("match_connections", filter=f'pairKey={pb_quote(pair_key)}')
if existing: if existing:
conversation = await sync_connection_conversation(existing)
if conversation and str(existing.get("conversationId") or "") != str(conversation.get("id") or ""):
existing = await pb.update_record(
"match_connections",
existing["id"],
{"conversationId": conversation["id"]},
)
return existing return existing
return await pb.create_record( connection = await pb.create_record(
"match_connections", "match_connections",
{ {
"pairKey": pair_key, "pairKey": pair_key,
@@ -444,6 +469,39 @@ async def ensure_match_connection(
"matchedAt": utc_now(), "matchedAt": utc_now(),
}, },
) )
conversation = await ensure_conversation_for_match(
user_a_id=user_a_id,
user_b_id=user_b_id,
match_connection_id=str(connection.get("id") or ""),
intent=intent or "friends",
)
return await pb.update_record(
"match_connections",
connection["id"],
{"conversationId": conversation["id"]},
)
def chat_action_url(conversation_id: str) -> str:
return f"/chat/{conversation_id}"
async def peer_profile_for_conversation(conversation: dict[str, Any], user_id: str) -> dict[str, Any] | None:
other_user_id = other_participant_id(conversation, user_id)
if not other_user_id:
return None
profile = await profile_for_user(other_user_id)
if not profile:
return None
return enrich_profile(profile)
async def decorate_conversation(conversation: dict[str, Any], user_id: str) -> dict[str, Any]:
peer = await peer_profile_for_conversation(conversation, user_id)
payload = dm_public_record(conversation)
payload["peer"] = peer
payload["unreadCount"] = unread_count_for(conversation, user_id)
return payload
def profile_matches_city(record: dict[str, Any], city_query: str) -> bool: def profile_matches_city(record: dict[str, Any], city_query: str) -> bool:
@@ -2201,6 +2259,8 @@ async def match_mutual(request: Request) -> dict[str, Any]:
payload["matchedAt"] = connection.get("matchedAt") or connection.get("created") or "" payload["matchedAt"] = connection.get("matchedAt") or connection.get("created") or ""
payload["intent"] = connection.get("intent") or "friends" payload["intent"] = connection.get("intent") or "friends"
payload["connectionId"] = connection.get("id") or "" payload["connectionId"] = connection.get("id") or ""
conversation = await sync_connection_conversation(connection)
payload["conversationId"] = conversation.get("id") or connection.get("conversationId") or ""
items.append(payload) items.append(payload)
return {"ok": True, "items": items, "total": len(items)} return {"ok": True, "items": items, "total": len(items)}
@@ -2261,6 +2321,8 @@ async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
intent=intent, intent=intent,
) )
my_name = str(my_profile.get("name") or "一位成员") my_name = str(my_profile.get("name") or "一位成员")
conversation_id = str(match_record.get("conversationId") or "")
chat_url = chat_action_url(conversation_id) if conversation_id else "/chat"
await create_notification( await create_notification(
title=f"互相喜欢 · {target_name}", title=f"互相喜欢 · {target_name}",
body=f"你和 {target_name} 互相喜欢了,可以发消息约咖啡或同城见面。", body=f"你和 {target_name} 互相喜欢了,可以发消息约咖啡或同城见面。",
@@ -2269,7 +2331,7 @@ async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
target_user_id=user_id, target_user_id=user_id,
audience="user", audience="user",
action_label="发消息", action_label="发消息",
action_url="/messages", action_url=chat_url,
entity_type="match_connection", entity_type="match_connection",
entity_id=match_record["id"], entity_id=match_record["id"],
icon="💕", icon="💕",
@@ -2282,7 +2344,7 @@ async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
target_user_id=target_user_id, target_user_id=target_user_id,
audience="user", audience="user",
action_label="发消息", action_label="发消息",
action_url="/messages", action_url=chat_url,
entity_type="match_connection", entity_type="match_connection",
entity_id=match_record["id"], entity_id=match_record["id"],
icon="💕", icon="💕",
@@ -2324,6 +2386,135 @@ async def save_swipe(payload: SwipePayload, request: Request) -> dict[str, Any]:
} }
@app.get("/api/conversations")
async def list_conversations(request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
conversations = await list_conversations_for_user(user["id"])
items: list[dict[str, Any]] = []
unread_total = 0
for conversation in conversations:
decorated = await decorate_conversation(conversation, user["id"])
unread_total += int(decorated.get("unreadCount") or 0)
items.append(decorated)
items.sort(
key=lambda item: str(item.get("lastMessageAt") or item.get("updated") or item.get("created") or ""),
reverse=True,
)
return {"ok": True, "items": items, "total": len(items), "unread": unread_total}
@app.get("/api/conversations/by-connection/{connection_id}")
async def conversation_for_connection(connection_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
conversation = await conversation_by_connection(connection_id, user["id"])
if not conversation:
raise HTTPException(status_code=404, detail="会话不存在")
return {"ok": True, "conversation": await decorate_conversation(conversation, user["id"])}
@app.get("/api/conversations/{conversation_id}")
async def get_conversation(conversation_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
conversation = await conversation_for_user(conversation_id, user["id"])
if not conversation:
raise HTTPException(status_code=404, detail="会话不存在")
return {"ok": True, "conversation": await decorate_conversation(conversation, user["id"])}
@app.get("/api/conversations/{conversation_id}/messages")
async def get_conversation_messages(
conversation_id: str,
request: Request,
limit: int = 50,
before: str = "",
) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
conversation = await conversation_for_user(conversation_id, user["id"])
if not conversation:
raise HTTPException(status_code=404, detail="会话不存在")
messages = await list_messages(conversation_id, limit=limit, before=before)
await mark_conversation_read(conversation, user["id"])
items = [
{
**dm_public_record(message),
"mine": str(message.get("senderId") or "") == user["id"],
}
for message in messages
]
return {"ok": True, "items": items, "total": len(items)}
@app.post("/api/conversations/{conversation_id}/messages")
async def post_conversation_message(
conversation_id: str,
payload: ChatMessagePayload,
request: Request,
) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
conversation = await conversation_for_user(conversation_id, user["id"])
if not conversation:
raise HTTPException(status_code=404, detail="会话不存在")
try:
message, recipient_id = await send_dm_message(
conversation=conversation,
sender_id=user["id"],
body=payload.body,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
sender_profile = await profile_for_user(user["id"])
sender_name = str((sender_profile or {}).get("name") or user.get("name") or "一位成员")
preview = payload.body.strip().replace("\n", " ")
if len(preview) > 120:
preview = f"{preview[:117]}..."
if recipient_id:
await create_notification(
title=f"新私信 · {sender_name}",
body=preview,
category="match",
priority="high",
target_user_id=recipient_id,
audience="user",
action_label="回复",
action_url=chat_action_url(conversation_id),
entity_type="conversation",
entity_id=conversation_id,
icon="💬",
)
return {
"ok": True,
"message": {
**dm_public_record(message),
"mine": True,
},
}
@app.post("/api/conversations/{conversation_id}/read")
async def mark_conversation_as_read(conversation_id: str, request: Request) -> dict[str, Any]:
_, user = await current_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
conversation = await conversation_for_user(conversation_id, user["id"])
if not conversation:
raise HTTPException(status_code=404, detail="会话不存在")
updated = await mark_conversation_read(conversation, user["id"])
return {"ok": True, "conversation": await decorate_conversation(updated, user["id"])}
@app.post("/api/feedback") @app.post("/api/feedback")
async def feedback(payload: FeedbackPayload) -> dict[str, Any]: async def feedback(payload: FeedbackPayload) -> dict[str, Any]:
record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"}) record = await pb.create_record("feedback", {**payload.model_dump(), "status": "new"})

View File

@@ -49,7 +49,8 @@
"remoteJobs": "Remote Jobs", "remoteJobs": "Remote Jobs",
"coworking": "Coworking", "coworking": "Coworking",
"digitalNomadGuide": "Digital Nomad Guide", "digitalNomadGuide": "Digital Nomad Guide",
"messages": "Messages" "messages": "Messages",
"privateChat": "Direct messages"
}, },
"nav": { "nav": {
"cities": "Cities", "cities": "Cities",
@@ -422,7 +423,7 @@
"passOverlay": "Pass", "passOverlay": "Pass",
"noLikesYet": "No likes yet — complete your profile and keep swiping", "noLikesYet": "No likes yet — complete your profile and keep swiping",
"noMutualYet": "No mutual matches yet — keep swiping", "noMutualYet": "No mutual matches yet — keep swiping",
"goMessages": "Open messages", "goMessages": "Open chats",
"editProfile": "Edit profile", "editProfile": "Edit profile",
"loginHint": "Sign in to save likes and get mutual match alerts.", "loginHint": "Sign in to save likes and get mutual match alerts.",
"loginAction": "Sign in / Join", "loginAction": "Sign in / Join",
@@ -559,6 +560,23 @@
"noData": "No member data yet", "noData": "No member data yet",
"demoHint": "This view is connected to local FastAPI and PocketBase." "demoHint": "This view is connected to local FastAPI and PocketBase."
}, },
"chat": {
"title": "Direct Messages",
"subtitle": "Chat one-on-one with members you mutually matched with.",
"notifications": "Notifications",
"goMatching": "Go to matching",
"loginRequired": "Sign in to view and send direct messages.",
"loginAction": "Sign in / Join",
"emptyTitle": "No conversations yet",
"emptySubtitle": "After a mutual match, a chat thread is created here automatically.",
"back": "Back",
"inputPlaceholder": "Type a message. Enter to send, Shift+Enter for newline",
"send": "Send",
"sending": "Sending",
"emptyThread": "Say hello to start your first conversation.",
"loadError": "Failed to load conversation",
"sendError": "Failed to send. Please try again."
},
"services": { "services": {
"title": "Services", "title": "Services",
"subtitle": "Premium services for digital nomads", "subtitle": "Premium services for digital nomads",

View File

@@ -49,7 +49,8 @@
"remoteJobs": "远程工作", "remoteJobs": "远程工作",
"coworking": "共享办公", "coworking": "共享办公",
"digitalNomadGuide": "数字游民指南", "digitalNomadGuide": "数字游民指南",
"messages": "站内消息" "messages": "站内消息",
"privateChat": "私信聊天"
}, },
"nav": { "nav": {
"cities": "城市", "cities": "城市",
@@ -422,7 +423,7 @@
"passOverlay": "跳过", "passOverlay": "跳过",
"noLikesYet": "还没有人喜欢你,完善资料并多滑动卡片吧", "noLikesYet": "还没有人喜欢你,完善资料并多滑动卡片吧",
"noMutualYet": "还没有互相喜欢,继续滑动试试", "noMutualYet": "还没有互相喜欢,继续滑动试试",
"goMessages": "去消息中心", "goMessages": "去私信聊天",
"editProfile": "完善资料", "editProfile": "完善资料",
"loginHint": "登录后可记录喜欢、接收互相喜欢提醒。", "loginHint": "登录后可记录喜欢、接收互相喜欢提醒。",
"loginAction": "登录 / 加入", "loginAction": "登录 / 加入",
@@ -559,6 +560,23 @@
"noData": "暂无成员分布数据", "noData": "暂无成员分布数据",
"demoHint": "当前内容已接入本机 FastAPI 与 PocketBase。" "demoHint": "当前内容已接入本机 FastAPI 与 PocketBase。"
}, },
"chat": {
"title": "私信",
"subtitle": "与互相喜欢的成员一对一聊天,约咖啡或同城见面。",
"notifications": "通知中心",
"goMatching": "去匹配",
"loginRequired": "登录后即可查看和发送私信。",
"loginAction": "登录 / 加入",
"emptyTitle": "还没有私信会话",
"emptySubtitle": "在智能匹配里互相喜欢后,会自动在这里创建聊天。",
"back": "返回",
"inputPlaceholder": "输入消息Enter 发送Shift+Enter 换行",
"send": "发送",
"sending": "发送中",
"emptyThread": "打个招呼,开始你们的第一次对话吧。",
"loadError": "加载会话失败",
"sendError": "发送失败,请稍后重试"
},
"services": { "services": {
"title": "增值服务", "title": "增值服务",
"subtitle": "数字游民专属增值服务", "subtitle": "数字游民专属增值服务",

View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""Build Next.js on production and restart cnomadcna.service."""
from __future__ import annotations
import sys
import paramiko
HOST = "82.157.112.245"
USER = "ubuntu"
PASSWORD = "Xiao4669805"
REPO = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna"
BUILD_CMD = (
f"cd {REPO} && "
"APP_ENV=production NEXT_PUBLIC_APP_ENV=production NODE_ENV=production "
"pnpm build && systemctl restart cnomadcna.service && "
"systemctl is-active cnomadcna.service"
)
def main() -> int:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
_, o, e = c.exec_command(
f"echo '{PASSWORD}' | sudo -S bash -lc \"{BUILD_CMD}\"",
timeout=900,
get_pty=True,
)
out = o.read().decode("utf-8", errors="replace")
err = e.read().decode("utf-8", errors="replace")
code = o.channel.recv_exit_status()
if out:
sys.stdout.buffer.write(out.encode("utf-8", errors="replace"))
if err:
sys.stdout.buffer.write(err.encode("utf-8", errors="replace"))
return code
finally:
c.close()
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
CMDS = [
"echo '{pw}' | sudo -S cat /etc/docker/daemon.json 2>/dev/null || echo 'no daemon.json'",
"echo '{pw}' | sudo -S docker info 2>/dev/null | grep -A5 'Registry Mirrors' || true",
"echo '{pw}' | sudo -S docker images --format '{{.Repository}}:{{.Tag}}' | sort",
"echo '{pw}' | sudo -S bash -lc 'cd /opt/nomadro-services/integrations && docker compose ps -a 2>/dev/null'",
"echo '{pw}' | sudo -S nginx -t 2>&1",
"curl -sI https://nomadro.cn/ | head -3",
]
def main() -> None:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
for cmd in CMDS:
cmd = cmd.format(pw=PASSWORD)
print(f"\n=== {cmd.replace(PASSWORD, '***')} ===")
_, o, e = c.exec_command(cmd, timeout=90, get_pty=True)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
finally:
c.close()
if __name__ == "__main__":
main()

40
scripts/ssh-check-ntfy.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
CMDS = [
"docker ps -a | grep -i ntfy || true",
"curl -sI http://127.0.0.1:9180/ | head -8",
"curl -sI https://ntfy.nomadro.cn/ | head -10",
"curl -skI https://ntfy.nomadro.cn/ | head -10",
"sudo cat /www/server/panel/vhost/nginx/ntfy.nomadro.cn.conf 2>/dev/null | head -60",
"sudo ls /etc/letsencrypt/live/ | grep ntfy || true",
"sudo certbot certificates 2>/dev/null | grep -A3 ntfy || true",
]
def main() -> None:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
for cmd in CMDS:
full = f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\""
print(f"\n=== {cmd} ===")
_, o, e = c.exec_command(full, timeout=60, get_pty=True)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
finally:
c.close()
if __name__ == "__main__":
main()

40
scripts/ssh-fix-deploy.py Normal file
View File

@@ -0,0 +1,40 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
CMDS = [
"echo '{pw}' | sudo -S ls /www/server/panel/vhost/nginx/ | grep nomadro",
"echo '{pw}' | sudo -S head -40 /www/server/panel/vhost/nginx/lounge.nomadro.cn.conf 2>/dev/null || echo '{pw}' | sudo -S head -40 /www/server/panel/vhost/nginx/mirotalk.nomadro.cn.conf",
"echo '{pw}' | sudo -S ls /etc/letsencrypt/live/ 2>/dev/null | head -20",
"echo '{pw}' | sudo -S docker images | head -20",
"echo '{pw}' | sudo -S cat /opt/nomadro-services/integrations/docker-compose.yml | head -30",
]
def main() -> None:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
for cmd in CMDS:
cmd = cmd.format(pw=PASSWORD)
print(f"\n=== {cmd.replace(PASSWORD, '***')} ===")
_, o, e = c.exec_command(cmd, timeout=60, get_pty=True)
out = o.read().decode("utf-8", errors="replace")
err = e.read().decode("utf-8", errors="replace")
sys.stdout.buffer.write(out.encode("utf-8", errors="replace"))
sys.stdout.buffer.write(err.encode("utf-8", errors="replace"))
finally:
c.close()
if __name__ == "__main__":
main()

47
scripts/ssh-fix-lounge.py Normal file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Diagnose and fix The Lounge + Ergo IRC on production VPS."""
from __future__ import annotations
import sys
import paramiko
import os
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "")
COMMANDS = [
"hostname",
"systemctl is-active thelounge.service ergo.service 2>&1 || true",
"systemctl status thelounge.service --no-pager -l 2>&1 | tail -25",
"systemctl status ergo.service --no-pager -l 2>&1 | tail -25",
"ss -ltnp 2>/dev/null | grep -E ':9000|:6667' || netstat -ltnp 2>/dev/null | grep -E ':9000|:6667' || true",
"curl -sI http://127.0.0.1:9000/ 2>&1 | head -8",
"curl -sI https://lounge.nomadro.cn/ 2>&1 | head -10",
"journalctl -u thelounge.service -n 30 --no-pager 2>&1",
"journalctl -u ergo.service -n 20 --no-pager 2>&1",
]
def run_remote(commands: list[str]) -> None:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASSWORD, timeout=25)
try:
for cmd in commands:
print(f"\n=== {cmd} ===")
_, stdout, stderr = client.exec_command(cmd, timeout=45)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
if out:
print(out.rstrip())
if err:
print("ERR:", err.rstrip())
finally:
client.close()
if __name__ == "__main__":
run_remote(COMMANDS if len(sys.argv) == 1 else sys.argv[1:])

View File

@@ -40,9 +40,11 @@ def main() -> int:
c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=SSH_PASSWORD, timeout=30) c.connect(HOST, username=USER, password=SSH_PASSWORD, timeout=30)
try: try:
run(c, f"cp {REPO}/.env.local /tmp/nomadcna.env.local.bak 2>/dev/null || true")
run(c, f"cd {REPO} && git fetch {auth_url} {BRANCH}") run(c, f"cd {REPO} && git fetch {auth_url} {BRANCH}")
run(c, f"cd {REPO} && git checkout {BRANCH}") run(c, f"cd {REPO} && git checkout {BRANCH}")
run(c, f"cd {REPO} && git pull {auth_url} {BRANCH}") run(c, f"cd {REPO} && git reset --hard FETCH_HEAD")
run(c, f"cp /tmp/nomadcna.env.local.bak {REPO}/.env.local 2>/dev/null || true")
run(c, "systemctl restart cnomadcna-api.service") run(c, "systemctl restart cnomadcna-api.service")
run(c, "systemctl is-active cnomadcna-api.service") run(c, "systemctl is-active cnomadcna-api.service")
run(c, f"cd {REPO} && git log -1 --oneline") run(c, f"cd {REPO} && git log -1 --oneline")

38
scripts/ssh-ntfy-auth.py Normal file
View File

@@ -0,0 +1,38 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
CMDS = [
"docker inspect nomadro-ntfy --format '{{json .Mounts}}'",
"sudo ls -la /opt/nomadro-services/ 2>/dev/null",
"sudo cat /opt/nomadro-services/docker-compose.yml 2>/dev/null | head -80",
"sudo ls -la /var/lib/ntfy/ 2>/dev/null",
"sudo cat /var/lib/ntfy/server.yml 2>/dev/null | head -40",
"docker exec nomadro-ntfy ntfy token list 2>/dev/null || true",
]
def main() -> None:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
for cmd in CMDS:
print(f"\n=== {cmd} ===")
_, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=60, get_pty=True)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
finally:
c.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""Patch join page TS fix on production."""
import base64
import sys
import paramiko
HOST = "82.157.112.245"
USER = "ubuntu"
PASSWORD = "Xiao4669805"
LOCAL = r"c:\Users\admin\Desktop\gitlab-instance-0a899031_cnomadcna\app\[locale]\join\page.tsx"
REMOTE = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna/app/[locale]/join/page.tsx"
TMP = "/tmp/join-page.tsx"
content = open(LOCAL, encoding="utf-8").read()
b64 = base64.b64encode(content.encode("utf-8")).decode()
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
cmd = (
f"python3 -c \"import base64; open('{TMP}','wb').write(base64.b64decode('{b64}'))\" "
f"&& cp {TMP} {REMOTE} && chown ubuntu:ubuntu {REMOTE} && echo uploaded"
)
_, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=120)
out = o.read().decode()
err = e.read().decode()
code = o.channel.recv_exit_status()
sys.stdout.write(out)
sys.stdout.write(err)
c.close()
sys.exit(code)

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
CMDS = [
"hostname; free -h | head -2; df -h / | tail -1",
"docker --version 2>&1; docker ps --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' 2>&1",
"ss -ltnp 2>/dev/null | grep -E ':5432|:3306|:9000|:9300|:8090|:4002|:8000|:9180|:9100|:3000|:8080|:1935|:1337|:8025|:9001' || true",
"ls /www/server/panel/vhost/nginx/*.conf 2>/dev/null | xargs -I{} basename {}",
"ls /etc/systemd/system/*.service 2>/dev/null | xargs -I{} basename {} | grep -E 'nomad|lounge|mirotalk|ergo|cnomad' || true",
"which nginx certbot 2>&1",
"curl -sI https://nomadro.cn/ | head -3",
]
def main() -> None:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=25)
try:
for cmd in CMDS:
print(f"\n=== {cmd} ===")
_, o, e = c.exec_command(cmd, timeout=45, get_pty=True)
out = o.read().decode("utf-8", errors="replace").strip()
err = e.read().decode("utf-8", errors="replace").strip()
if out:
print(out)
if err:
print("ERR:", err)
finally:
c.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Restore corrupted The Lounge config.js on production VPS."""
from __future__ import annotations
import paramiko
import os
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "")
INSPECT = [
"cat /var/lib/thelounge/config.js 2>&1 | head -20",
"ls -la /var/lib/thelounge/ 2>&1",
"ls -la /var/lib/thelounge/*.bak /var/lib/thelounge/config.js.* 2>&1 || true",
"cat /etc/systemd/system/thelounge.service 2>&1",
"sudo ls -la /var/lib/thelounge/ 2>&1",
]
LOUNGE_CONFIG = r"""'use strict';
module.exports = {
host: '127.0.0.1',
port: 9000,
public: true,
theme: 'morning',
prefetch: false,
disableLog: false,
fileUpload: false,
transports: ['websocket', 'polling'],
defaults: {
name: 'NomadCNA',
username: 'nomad',
realname: 'NomadCNA Community',
join: '#nomadcna,#online-events',
},
lockNetwork: true,
messageStorage: [],
useHexIp: false,
maxHistory: 10000,
displayNetwork: true,
networks: [
{
name: 'NomadCNA',
host: '127.0.0.1',
port: 6667,
tls: false,
rejectUnauthorized: false,
nick: 'nomad',
username: 'nomad',
realname: 'NomadCNA Community',
join: '#nomadcna,#online-events',
commands: [],
},
],
};
"""
def run(commands: list[str]) -> None:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASSWORD, timeout=25)
try:
for cmd in commands:
print(f"\n=== {cmd} ===")
_, stdout, stderr = client.exec_command(cmd, timeout=60, get_pty=True)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
if out:
print(out.rstrip())
if err:
print("ERR:", err.rstrip())
finally:
client.close()
def restore() -> None:
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(HOST, username=USER, password=PASSWORD, timeout=25)
try:
sftp = client.open_sftp()
remote_tmp = "/tmp/thelounge-config.js"
with sftp.file(remote_tmp, "w") as f:
f.write(LOUNGE_CONFIG)
sftp.close()
cmds = [
f"echo '{PASSWORD}' | sudo -S cp /var/lib/thelounge/config.js /var/lib/thelounge/config.js.broken-$(date +%Y%m%d%H%M%S) 2>/dev/null || true",
f"echo '{PASSWORD}' | sudo -S cp {remote_tmp} /var/lib/thelounge/config.js",
f"echo '{PASSWORD}' | sudo -S chown thelounge:thelounge /var/lib/thelounge/config.js",
f"echo '{PASSWORD}' | sudo -S chmod 640 /var/lib/thelounge/config.js",
f"echo '{PASSWORD}' | sudo -S systemctl restart thelounge.service",
"sleep 3",
"systemctl is-active thelounge.service",
"curl -sI http://127.0.0.1:9000/ | head -8",
"curl -sI https://lounge.nomadro.cn/ | head -8",
"journalctl -u thelounge.service -n 15 --no-pager",
]
for cmd in cmds:
print(f"\n=== {cmd.replace(PASSWORD, '***')} ===")
_, stdout, stderr = client.exec_command(cmd, timeout=60, get_pty=True)
out = stdout.read().decode("utf-8", errors="replace")
err = stderr.read().decode("utf-8", errors="replace")
if out:
print(out.rstrip())
if err:
print("ERR:", err.rstrip())
finally:
client.close()
VERIFY = [
"systemctl is-active thelounge.service ergo.service",
"curl -s 'https://lounge.nomadro.cn/socket.io/?EIO=4&transport=polling&t=health' | cut -c1-120",
"{ printf 'NICK health\\r\\nUSER health 0 * :NomadCNA Health\\r\\nJOIN #nomadcna\\r\\nQUIT :healthcheck\\r\\n'; sleep 1; } | timeout 5s nc 127.0.0.1 6667 2>&1 | head -5",
"curl -sI 'https://lounge.nomadro.cn/' | head -3",
]
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "restore":
restore()
elif len(sys.argv) > 1 and sys.argv[1] == "verify":
run(VERIFY)
else:
run(INSPECT)

View File

@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""Configure ntfy ACL: public read on nomadcna-* topics, backend publish token."""
from __future__ import annotations
import os
import re
import secrets
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
def run(c: paramiko.SSHClient, cmd: str, timeout: int = 120) -> str:
print(f"\n$ {cmd.replace(PASSWORD, '***')}")
_, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=timeout, get_pty=True)
out = o.read().decode("utf-8", errors="replace")
err = e.read().decode("utf-8", errors="replace")
sys.stdout.buffer.write(out.encode("utf-8", errors="replace"))
sys.stdout.buffer.write(err.encode("utf-8", errors="replace"))
return out + err
def main() -> None:
api_pass = secrets.token_urlsafe(16)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
run(c, "docker exec nomadro-ntfy ntfy user add --role=admin nomadcna-api --pass='{}' 2>/dev/null || true".format(api_pass))
run(c, "docker exec nomadro-ntfy ntfy access nomadcna-* read everyone")
run(c, "docker exec nomadro-ntfy ntfy access nomadcna-* write nomadcna-api")
token_out = run(c, "docker exec nomadro-ntfy ntfy token add --user=nomadcna-api nomadcna-backend 2>&1")
token_match = re.search(r"tk_[a-zA-Z0-9_-]+", token_out)
token = token_match.group(0) if token_match else ""
if token:
run(c, f"grep -q NTFY_AUTH_TOKEN /opt/nomadro-services/.env || echo 'NTFY_AUTH_TOKEN={token}' >> /opt/nomadro-services/.env")
run(c, f"grep -q NTFY_AUTH_TOKEN /home/ubuntu/gitlab-instance-0a899031_cnomadcna/.env.local 2>/dev/null || echo 'NTFY_AUTH_TOKEN={token}' >> /home/ubuntu/gitlab-instance-0a899031_cnomadcna/.env.local")
test = run(
c,
f"curl -s -X POST https://ntfy.nomadro.cn/nomadcna-healthcheck "
f"-H 'Authorization: Bearer {token}' -H 'Title: NomadCNA' -d 'push ok'",
)
print("\n=== Token (save to FastAPI env) ===")
print(token or "(check token add output above)")
print("=== Test response ===")
print(test.strip()[:200])
finally:
c.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import re
import secrets
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
def run(c: paramiko.SSHClient, cmd: str) -> str:
print(f"\n$ {cmd.replace(PASSWORD, '***')}")
_, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=120, get_pty=True)
out = o.read().decode("utf-8", errors="replace")
err = e.read().decode("utf-8", errors="replace")
combined = out + err
sys.stdout.buffer.write(combined.encode("utf-8", errors="replace"))
return combined
def main() -> None:
api_pass = secrets.token_urlsafe(12)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
run(c, "docker exec nomadro-ntfy ntfy user list 2>&1")
run(c, "docker exec nomadro-ntfy ntfy access --help 2>&1 | head -25")
run(c, f"docker exec nomadro-ntfy ntfy user add --role=admin nomadcna-api --pass={api_pass} 2>&1")
run(c, "docker exec nomadro-ntfy ntfy access nomadcna-* everyone read 2>&1")
run(c, "docker exec nomadro-ntfy ntfy access nomadcna-* nomadcna-api write-only 2>&1")
token_out = run(c, "docker exec nomadro-ntfy ntfy token add -l nomadcna-backend nomadcna-api 2>&1")
token_match = re.search(r"tk_[a-zA-Z0-9_-]+", token_out)
token = token_match.group(0) if token_match else ""
if not token:
run(c, "docker exec nomadro-ntfy ntfy token list nomadcna-api 2>&1")
if token:
repo_env = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna/.env.local"
run(c, f"grep -q '^NTFY_AUTH_TOKEN=' {repo_env} 2>/dev/null && sed -i 's/^NTFY_AUTH_TOKEN=.*/NTFY_AUTH_TOKEN={token}/' {repo_env} || echo 'NTFY_AUTH_TOKEN={token}' >> {repo_env}")
run(
c,
f"curl -s -X POST https://ntfy.nomadro.cn/nomadcna-healthcheck "
f"-H 'Authorization: Bearer {token}' -H 'Title: NomadCNA' -d 'push ok'",
)
run(c, "systemctl restart cnomadcna-api.service 2>/dev/null || true")
print(f"\nTOKEN={token}")
finally:
c.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,63 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import re
import secrets
import sys
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
def run(c: paramiko.SSHClient, cmd: str) -> str:
print(f"\n$ {cmd.replace(PASSWORD, '***')}")
_, o, e = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=120, get_pty=True)
combined = o.read().decode("utf-8", errors="replace") + e.read().decode("utf-8", errors="replace")
sys.stdout.buffer.write(combined.encode("utf-8", errors="replace"))
return combined
def main() -> None:
api_pass = secrets.token_urlsafe(16)
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
try:
run(c, "docker exec nomadro-ntfy ntfy user add --help 2>&1 | head -30")
run(c, f"docker exec nomadro-ntfy ntfy user add nomadcna-api --role=writer --passwd={api_pass} 2>&1")
run(c, "docker exec nomadro-ntfy ntfy access '*' 'nomadcna-*' read 2>&1")
run(c, "docker exec nomadro-ntfy ntfy access nomadcna-api 'nomadcna-*' write-only 2>&1")
run(c, "docker exec nomadro-ntfy ntfy user list 2>&1")
token_out = run(c, "docker exec nomadro-ntfy ntfy token add -l nomadcna-backend nomadcna-api 2>&1")
token = re.search(r"tk_[a-zA-Z0-9_-]+", token_out)
token_val = token.group(0) if token else ""
if not token_val:
token_out2 = run(c, "docker exec nomadro-ntfy ntfy token add -l nomadcna-backend nomadro 2>&1")
token2 = re.search(r"tk_[a-zA-Z0-9_-]+", token_out2)
token_val = token2.group(0) if token2 else ""
if token_val:
repo = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna/.env.local"
run(
c,
f"touch {repo} && grep -q '^NTFY_AUTH_TOKEN=' {repo} && "
f"sed -i 's/^NTFY_AUTH_TOKEN=.*/NTFY_AUTH_TOKEN={token_val}/' {repo} || "
f"echo 'NTFY_AUTH_TOKEN={token_val}' >> {repo}",
)
run(
c,
f"curl -s -X POST https://ntfy.nomadro.cn/nomadcna-healthcheck "
f"-H 'Authorization: Bearer {token_val}' -H 'Title: NomadCNA' -d 'push ok'",
)
run(c, "systemctl restart cnomadcna-api.service")
run(c, "systemctl is-active cnomadcna-api.service")
print(f"\nNTFY_AUTH_TOKEN={token_val}")
finally:
c.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,52 @@
#!/usr/bin/env python3
from __future__ import annotations
import os
import sys
from pathlib import Path
import paramiko
HOST = os.getenv("NOMAD_SSH_HOST", "82.157.112.245")
USER = os.getenv("NOMAD_SSH_USER", "ubuntu")
PASSWORD = os.getenv("NOMAD_SSH_PASSWORD", "Xiao4669805")
REPO = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna"
ROOT = Path(__file__).resolve().parent.parent
FILES = [
"backend/ntfy_push.py",
"backend/main.py",
"backend/settings.py",
]
def main() -> None:
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
sftp = c.open_sftp()
try:
for rel in FILES:
local = ROOT / rel
tmp = f"/tmp/{Path(rel).name}"
dest = f"{REPO}/{rel.replace(chr(92), '/')}"
sftp.put(str(local), tmp)
_, o, _ = c.exec_command(
f"echo '{PASSWORD}' | sudo -S cp {tmp} {dest} && sudo chown ubuntu:ubuntu {dest}",
timeout=60,
get_pty=True,
)
print(f"uploaded {rel}: {o.read().decode().strip()}")
_, o, _ = c.exec_command(
f"echo '{PASSWORD}' | sudo -S systemctl restart cnomadcna-api.service && systemctl is-active cnomadcna-api.service",
timeout=60,
get_pty=True,
)
sys.stdout.buffer.write(o.read())
finally:
sftp.close()
c.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Quick production health checks after deploy."""
from __future__ import annotations
import paramiko
HOST = "82.157.112.245"
USER = "ubuntu"
PASSWORD = "Xiao4669805"
REPO = "/home/ubuntu/gitlab-instance-0a899031_cnomadcna"
cmds = [
"curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8000/docs",
f"test -f {REPO}/backend/ntfy_push.py && echo ntfy_push:ok",
"grep -E '^(NTFY_|LISTMONK_)' /home/ubuntu/gitlab-instance-0a899031_cnomadcna/.env.local | sed 's/=.*/=***/'",
"systemctl is-active cnomadcna-api.service",
"systemctl is-active cnomadcna.service",
"systemctl cat cnomadcna.service | head -20",
]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect(HOST, username=USER, password=PASSWORD, timeout=30)
for cmd in cmds:
_, o, _ = c.exec_command(f"echo '{PASSWORD}' | sudo -S bash -lc \"{cmd}\"", timeout=60)
out = o.read().decode().strip()
print(f"$ {cmd}\n -> {out}\n")
c.close()