's'
This commit is contained in:
@@ -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
|
||||||
|
|||||||
243
app/[locale]/chat/[id]/page.tsx
Normal file
243
app/[locale]/chat/[id]/page.tsx
Normal 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
159
app/[locale]/chat/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
81
app/lib/chat.ts
Normal 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({}) });
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
228
backend/dm.py
Normal file
228
backend/dm.py
Normal 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
|
||||||
@@ -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),
|
||||||
|
|||||||
197
backend/main.py
197
backend/main.py
@@ -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"})
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "数字游民专属增值服务",
|
||||||
|
|||||||
44
scripts/ssh-build-frontend-prod.py
Normal file
44
scripts/ssh-build-frontend-prod.py
Normal 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())
|
||||||
39
scripts/ssh-check-docker-mirror.py
Normal file
39
scripts/ssh-check-docker-mirror.py
Normal 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
40
scripts/ssh-check-ntfy.py
Normal 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
40
scripts/ssh-fix-deploy.py
Normal 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
47
scripts/ssh-fix-lounge.py
Normal 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:])
|
||||||
@@ -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
38
scripts/ssh-ntfy-auth.py
Normal 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()
|
||||||
32
scripts/ssh-patch-join-page.py
Normal file
32
scripts/ssh-patch-join-page.py
Normal 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)
|
||||||
41
scripts/ssh-probe-server.py
Normal file
41
scripts/ssh-probe-server.py
Normal 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()
|
||||||
131
scripts/ssh-restore-lounge.py
Normal file
131
scripts/ssh-restore-lounge.py
Normal 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)
|
||||||
58
scripts/ssh-setup-ntfy-acl.py
Normal file
58
scripts/ssh-setup-ntfy-acl.py
Normal 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()
|
||||||
57
scripts/ssh-setup-ntfy-acl2.py
Normal file
57
scripts/ssh-setup-ntfy-acl2.py
Normal 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()
|
||||||
63
scripts/ssh-setup-ntfy-acl3.py
Normal file
63
scripts/ssh-setup-ntfy-acl3.py
Normal 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()
|
||||||
52
scripts/ssh-sync-ntfy-backend.py
Normal file
52
scripts/ssh-sync-ntfy-backend.py
Normal 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()
|
||||||
28
scripts/ssh-verify-prod.py
Normal file
28
scripts/ssh-verify-prod.py
Normal 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()
|
||||||
Reference in New Issue
Block a user