feat: chat E2EE MVP - rooms list + timeline + composer + sync (issues 0148+0149+0150)
Backend extends MatrixService with Start()/Stop()/ListRooms()/LoadTimeline()/
SendText()/SendMarkdown(). On login the service initialises the crypto store
(cryptohelper, Olm/Megolm via goolm build tag) and a sync loop that fans
events out through Wails events ("matrix:event", "matrix:error"). Pickle
key is 32 random bytes hex-encoded in the OS keyring alongside the access
token, so the crypto SQLite store survives restarts.
Vendors 4 fresh helpers from fn_registry/functions/infra/:
matrix_crypto_init.go (//go:build goolm || libolm)
matrix_sync_service.go
matrix_message_send.go
matrix_room_list.go
Plus the existing 3 (mas_oidc_loopback, keyring_token_store, matrix_client_init).
go-sqlite3 driver pulled explicitly via sqlite_driver.go.
Frontend rewires HomeScreen as a 3-zone AppShell (sidebar / timeline /
composer). useMatrixRooms polls + reacts to the sync stream; useMatrixTimeline
loads the last 50 events of the selected room and appends live ones. New
components: RoomList, Timeline, EventBubble, Composer. Composer supports
plain text (default) and a markdown toggle; Enter sends, Shift+Enter newline.
wails.json now passes "build:tags": "goolm" by default. Tested with
wails build -tags goolm on linux/amd64 and windows/amd64.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+184
-50
@@ -1,27 +1,39 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AppShell,
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Burger,
|
||||
Button,
|
||||
Code,
|
||||
Center,
|
||||
Group,
|
||||
Paper,
|
||||
Loader,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { IconLogout, IconUserCircle } from "@tabler/icons-react";
|
||||
import { GetSession, Logout } from "../wailsjs/go/main/MatrixService";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import {
|
||||
IconLock,
|
||||
IconLogout,
|
||||
IconUserCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import {
|
||||
Logout,
|
||||
SendMarkdown,
|
||||
SendText,
|
||||
Start,
|
||||
Stop,
|
||||
} from "../wailsjs/go/main/MatrixService";
|
||||
import { EventsOn } from "../wailsjs/runtime/runtime";
|
||||
import RoomList from "./components/RoomList";
|
||||
import Timeline from "./components/Timeline";
|
||||
import Composer from "./components/Composer";
|
||||
import { useMatrixRooms } from "./hooks/useMatrixRooms";
|
||||
import { useMatrixTimeline } from "./hooks/useMatrixTimeline";
|
||||
|
||||
interface Session {
|
||||
user_id: string;
|
||||
device_id: string;
|
||||
homeserver_url: string;
|
||||
has_token: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
const NAVBAR_WIDTH = 300;
|
||||
|
||||
export default function HomeScreen({
|
||||
userID,
|
||||
@@ -30,12 +42,63 @@ export default function HomeScreen({
|
||||
userID: string;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
const [navOpen, navHandlers] = useDisclosure(true);
|
||||
const [activeRoomID, setActiveRoomID] = useState<string | null>(null);
|
||||
const [started, setStarted] = useState(false);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
|
||||
const { rooms } = useMatrixRooms(started);
|
||||
const { events, loading: timelineLoading, error: timelineError } = useMatrixTimeline(
|
||||
activeRoomID,
|
||||
50,
|
||||
);
|
||||
|
||||
// Boot sync on mount; tear down on unmount.
|
||||
useEffect(() => {
|
||||
GetSession(userID).then((s) => setSession(s as Session | null));
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await Start(userID);
|
||||
if (!cancelled) setStarted(true);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) {
|
||||
const msg = String(e?.message ?? e);
|
||||
setStartError(msg);
|
||||
notifications.show({
|
||||
title: "Sync error",
|
||||
message: msg,
|
||||
color: "red",
|
||||
autoClose: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
Stop().catch(() => {});
|
||||
};
|
||||
}, [userID]);
|
||||
|
||||
// Show transient Matrix errors as toast notifications.
|
||||
useEffect(() => {
|
||||
const off = EventsOn("matrix:error", (msg: string) => {
|
||||
notifications.show({
|
||||
title: "Matrix",
|
||||
message: String(msg),
|
||||
color: "orange",
|
||||
autoClose: 6000,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (typeof off === "function") off();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const activeRoom = useMemo(
|
||||
() => rooms.find((r) => r.room_id === activeRoomID) || null,
|
||||
[rooms, activeRoomID],
|
||||
);
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await Logout(userID);
|
||||
@@ -44,22 +107,43 @@ export default function HomeScreen({
|
||||
}
|
||||
}
|
||||
|
||||
const initials = userID
|
||||
.replace("@", "")
|
||||
.split(":")[0]
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
async function handleSendText(body: string) {
|
||||
if (!activeRoomID) return;
|
||||
await SendText(activeRoomID, body);
|
||||
}
|
||||
|
||||
async function handleSendMarkdown(md: string) {
|
||||
if (!activeRoomID) return;
|
||||
await SendMarkdown(activeRoomID, md);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppShell header={{ height: 56 }} padding="md">
|
||||
<AppShell
|
||||
header={{ height: 56 }}
|
||||
navbar={{
|
||||
width: NAVBAR_WIDTH,
|
||||
breakpoint: 0,
|
||||
collapsed: { mobile: !navOpen, desktop: !navOpen },
|
||||
}}
|
||||
padding={0}
|
||||
>
|
||||
<AppShell.Header>
|
||||
<Group h="100%" px="md" justify="space-between">
|
||||
<Group gap="xs">
|
||||
<Group h="100%" px="md" justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Burger
|
||||
opened={navOpen}
|
||||
onClick={navHandlers.toggle}
|
||||
size="sm"
|
||||
aria-label="Toggle rooms"
|
||||
/>
|
||||
<IconUserCircle size={22} />
|
||||
<Text fw={600}>matrix_client_pc</Text>
|
||||
<Badge size="sm" variant="light" color="violet">
|
||||
v0.1.0
|
||||
v0.2.0
|
||||
</Badge>
|
||||
<Text size="xs" c="dimmed" visibleFrom="sm">
|
||||
{userID}
|
||||
</Text>
|
||||
</Group>
|
||||
<Button
|
||||
variant="subtle"
|
||||
@@ -71,41 +155,91 @@ export default function HomeScreen({
|
||||
</Button>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
<AppShell.Main>
|
||||
<Box maw={720} mx="auto" mt="xl">
|
||||
<Paper p="xl" radius="lg" withBorder>
|
||||
<Group gap="lg" align="flex-start">
|
||||
<Avatar size={64} color="violet" radius="xl">
|
||||
{initials}
|
||||
</Avatar>
|
||||
<Stack gap={6} flex={1}>
|
||||
<Title order={3}>{userID}</Title>
|
||||
{session ? (
|
||||
<Stack gap={4}>
|
||||
|
||||
<AppShell.Navbar>
|
||||
<RoomList
|
||||
rooms={rooms}
|
||||
activeRoomID={activeRoomID}
|
||||
onSelect={setActiveRoomID}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
<AppShell.Main
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "calc(100vh - 56px)",
|
||||
}}
|
||||
>
|
||||
{!started && !startError && (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Stack align="center" gap="xs">
|
||||
<Loader />
|
||||
<Text size="sm" c="dimmed">
|
||||
Device: <Code>{session.device_id || "-"}</Code>
|
||||
Starting sync and crypto (up to 60s on first run)...
|
||||
</Text>
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{startError && (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Stack align="center" gap="xs" maw={520} ta="center">
|
||||
<Title order={4} c="red">
|
||||
Could not start sync
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Homeserver: <Code>{session.homeserver_url}</Code>
|
||||
{startError}
|
||||
</Text>
|
||||
{session.expires_at && (
|
||||
</Stack>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{started && !activeRoom && (
|
||||
<Center style={{ flex: 1 }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick a room from the sidebar to view messages
|
||||
</Text>
|
||||
</Center>
|
||||
)}
|
||||
|
||||
{started && activeRoom && (
|
||||
<>
|
||||
<Box p="md" bg="dark.7" style={{ borderBottom: "1px solid var(--mantine-color-dark-5)" }}>
|
||||
<Group gap="xs">
|
||||
<Title order={5}>{activeRoom.name || activeRoom.room_id}</Title>
|
||||
{activeRoom.is_encrypted && (
|
||||
<Badge size="xs" color="green" variant="light" leftSection={<IconLock size={10} />}>
|
||||
E2EE
|
||||
</Badge>
|
||||
)}
|
||||
<Text size="xs" c="dimmed">
|
||||
Token expira: {session.expires_at}
|
||||
{activeRoom.member_count} member{activeRoom.member_count === 1 ? "" : "s"}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">Cargando sesion...</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Group>
|
||||
</Paper>
|
||||
<Paper p="md" radius="md" withBorder mt="lg">
|
||||
<Text size="sm" c="dimmed">
|
||||
Login OK. Sync + rooms + timeline llegan en issue 0148.
|
||||
{activeRoom.topic && (
|
||||
<Text size="xs" c="dimmed" mt={4} lineClamp={1}>
|
||||
{activeRoom.topic}
|
||||
</Text>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
||||
<Timeline
|
||||
events={events}
|
||||
loading={timelineLoading}
|
||||
error={timelineError}
|
||||
selfUserID={userID}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Composer
|
||||
disabled={!activeRoomID}
|
||||
onSendText={handleSendText}
|
||||
onSendMarkdown={handleSendMarkdown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState, useRef, KeyboardEvent } from "react";
|
||||
import { ActionIcon, Group, Textarea, Tooltip } from "@mantine/core";
|
||||
import { IconSend, IconMarkdown } from "@tabler/icons-react";
|
||||
|
||||
export interface ComposerProps {
|
||||
disabled?: boolean;
|
||||
onSendText: (body: string) => Promise<void>;
|
||||
onSendMarkdown: (md: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export default function Composer({ disabled, onSendText, onSendMarkdown }: ComposerProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [markdownMode, setMarkdownMode] = useState(false);
|
||||
const ref = useRef<HTMLTextAreaElement | null>(null);
|
||||
|
||||
async function doSend() {
|
||||
const body = value.trim();
|
||||
if (!body || disabled || sending) return;
|
||||
setSending(true);
|
||||
try {
|
||||
if (markdownMode) {
|
||||
await onSendMarkdown(body);
|
||||
} else {
|
||||
await onSendText(body);
|
||||
}
|
||||
setValue("");
|
||||
ref.current?.focus();
|
||||
} catch (e) {
|
||||
// surface via console; toast wiring is centralised in HomeScreen
|
||||
console.error("send failed", e);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
doSend();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Group gap="xs" align="flex-end" p="md" bg="dark.7" wrap="nowrap">
|
||||
<Tooltip label={markdownMode ? "Markdown ON" : "Markdown OFF"} withArrow>
|
||||
<ActionIcon
|
||||
variant={markdownMode ? "filled" : "subtle"}
|
||||
color="violet"
|
||||
size="lg"
|
||||
onClick={() => setMarkdownMode((v) => !v)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<IconMarkdown size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Textarea
|
||||
ref={ref}
|
||||
flex={1}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={6}
|
||||
radius="md"
|
||||
placeholder={
|
||||
disabled
|
||||
? "Select a room to start chatting"
|
||||
: markdownMode
|
||||
? "Markdown · Enter to send, Shift+Enter newline"
|
||||
: "Message · Enter to send, Shift+Enter newline"
|
||||
}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled || sending}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="filled"
|
||||
color="violet"
|
||||
size="lg"
|
||||
onClick={doSend}
|
||||
loading={sending}
|
||||
disabled={disabled || !value.trim()}
|
||||
>
|
||||
<IconSend size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Avatar, Group, Paper, Stack, Text } from "@mantine/core";
|
||||
import { IconLockExclamation } from "@tabler/icons-react";
|
||||
import type { MatrixEvent } from "../types";
|
||||
|
||||
function senderInitials(sender: string): string {
|
||||
return sender.replace(/^@/, "").charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function senderShort(sender: string): string {
|
||||
// "@local:server" -> "@local"
|
||||
return sender.split(":")[0];
|
||||
}
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export interface EventBubbleProps {
|
||||
event: MatrixEvent;
|
||||
isSelf: boolean;
|
||||
}
|
||||
|
||||
export default function EventBubble({ event, isSelf }: EventBubbleProps) {
|
||||
const isEncrypted = event.encrypted_raw;
|
||||
return (
|
||||
<Group
|
||||
align="flex-start"
|
||||
gap="xs"
|
||||
wrap="nowrap"
|
||||
justify={isSelf ? "flex-end" : "flex-start"}
|
||||
>
|
||||
{!isSelf && (
|
||||
<Avatar size={28} radius="xl" color="violet">
|
||||
{senderInitials(event.sender)}
|
||||
</Avatar>
|
||||
)}
|
||||
<Stack gap={2} maw="70%" align={isSelf ? "flex-end" : "flex-start"}>
|
||||
<Group gap={6}>
|
||||
<Text fz="xs" c="dimmed" fw={500}>
|
||||
{senderShort(event.sender)}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{fmtTime(event.ts)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Paper
|
||||
p="xs"
|
||||
radius="md"
|
||||
bg={isSelf ? "violet.9" : "dark.6"}
|
||||
c={isSelf ? "white" : "gray.0"}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{isEncrypted ? (
|
||||
<Group gap={6} c="dimmed">
|
||||
<IconLockExclamation size={14} />
|
||||
<Text fz="sm" fs="italic">
|
||||
Encrypted message (no session)
|
||||
</Text>
|
||||
</Group>
|
||||
) : (
|
||||
<Text fz="sm" style={{ whiteSpace: "pre-wrap" }}>
|
||||
{event.body || (
|
||||
<Text component="span" c="dimmed" fs="italic">
|
||||
(no body)
|
||||
</Text>
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</Stack>
|
||||
{isSelf && (
|
||||
<Avatar size={28} radius="xl" color="violet">
|
||||
{senderInitials(event.sender)}
|
||||
</Avatar>
|
||||
)}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Avatar, Group, NavLink, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import { IconLock, IconHash, IconUser } from "@tabler/icons-react";
|
||||
import type { RoomSummary } from "../types";
|
||||
|
||||
function relativeTime(ts: number): string {
|
||||
if (!ts) return "";
|
||||
const diff = Date.now() - ts;
|
||||
const sec = Math.floor(diff / 1000);
|
||||
if (sec < 60) return `${sec}s`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return `${min}m`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return `${hr}h`;
|
||||
const day = Math.floor(hr / 24);
|
||||
if (day < 30) return `${day}d`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
function initial(room: RoomSummary): string {
|
||||
const base = room.name || room.canonical_alias || room.room_id;
|
||||
return (base.replace(/^[#@!]/, "").charAt(0) || "?").toUpperCase();
|
||||
}
|
||||
|
||||
export interface RoomListProps {
|
||||
rooms: RoomSummary[];
|
||||
activeRoomID: string | null;
|
||||
onSelect: (roomID: string) => void;
|
||||
}
|
||||
|
||||
export default function RoomList({ rooms, activeRoomID, onSelect }: RoomListProps) {
|
||||
if (!rooms.length) {
|
||||
return (
|
||||
<Stack p="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
No rooms yet. Sync in progress...
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="calc(100vh - 56px)" type="hover" offsetScrollbars>
|
||||
<Stack gap={0} p={4}>
|
||||
{rooms.map((room) => {
|
||||
const active = room.room_id === activeRoomID;
|
||||
const TypeIcon = room.is_direct ? IconUser : IconHash;
|
||||
return (
|
||||
<NavLink
|
||||
key={room.room_id}
|
||||
active={active}
|
||||
variant="filled"
|
||||
color="violet"
|
||||
onClick={() => onSelect(room.room_id)}
|
||||
leftSection={
|
||||
<Avatar size={32} radius="xl" color={active ? "violet" : "gray"}>
|
||||
{initial(room)}
|
||||
</Avatar>
|
||||
}
|
||||
label={
|
||||
<Group gap={6} wrap="nowrap" justify="space-between">
|
||||
<Group gap={4} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<TypeIcon size={12} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
<Text truncate fz="sm" fw={active ? 600 : 500}>
|
||||
{room.name || room.canonical_alias || room.room_id}
|
||||
</Text>
|
||||
</Group>
|
||||
{room.is_encrypted && (
|
||||
<IconLock size={12} style={{ flexShrink: 0, opacity: 0.7 }} />
|
||||
)}
|
||||
</Group>
|
||||
}
|
||||
description={
|
||||
<Group gap={6} justify="space-between" wrap="nowrap">
|
||||
<Text fz="xs" c="dimmed" truncate>
|
||||
{room.member_count} member{room.member_count === 1 ? "" : "s"}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{relativeTime(room.last_event_ts)}
|
||||
</Text>
|
||||
</Group>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Box, Center, Loader, ScrollArea, Stack, Text } from "@mantine/core";
|
||||
import EventBubble from "./EventBubble";
|
||||
import type { MatrixEvent } from "../types";
|
||||
|
||||
export interface TimelineProps {
|
||||
events: MatrixEvent[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
selfUserID: string;
|
||||
}
|
||||
|
||||
export default function Timeline({ events, loading, error, selfUserID }: TimelineProps) {
|
||||
const viewport = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Auto-scroll to bottom when new events arrive.
|
||||
useEffect(() => {
|
||||
if (!viewport.current) return;
|
||||
viewport.current.scrollTo({
|
||||
top: viewport.current.scrollHeight,
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [events.length]);
|
||||
|
||||
if (loading && events.length === 0) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text c="red" size="sm">
|
||||
{error}
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (!events.length) {
|
||||
return (
|
||||
<Center h="100%">
|
||||
<Text size="sm" c="dimmed">
|
||||
No messages yet
|
||||
</Text>
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollArea h="100%" viewportRef={viewport} offsetScrollbars>
|
||||
<Box p="md">
|
||||
<Stack gap="sm">
|
||||
{events.map((ev) => (
|
||||
<EventBubble key={ev.event_id} event={ev} isSelf={ev.sender === selfUserID} />
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ListRooms } from "../../wailsjs/go/main/MatrixService";
|
||||
import { EventsOn } from "../../wailsjs/runtime/runtime";
|
||||
import type { RoomSummary, SyncEventView } from "../types";
|
||||
|
||||
/**
|
||||
* useMatrixRooms keeps a fresh list of joined rooms. It polls every 30s and
|
||||
* re-fetches on every relevant matrix:event so that recently active rooms
|
||||
* float to the top of the sidebar without a hard refresh.
|
||||
*/
|
||||
export function useMatrixRooms(enabled: boolean) {
|
||||
const [rooms, setRooms] = useState<RoomSummary[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inFlight = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (inFlight.current) return;
|
||||
inFlight.current = true;
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await ListRooms();
|
||||
setRooms(list || []);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
inFlight.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
refresh();
|
||||
const interval = window.setInterval(refresh, 30_000);
|
||||
const off = EventsOn("matrix:event", (ev: SyncEventView) => {
|
||||
// Only refresh for events that may move a room in the list.
|
||||
if (
|
||||
ev?.type === "message" ||
|
||||
ev?.type === "encrypted" ||
|
||||
ev?.type === "membership"
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
if (typeof off === "function") off();
|
||||
};
|
||||
}, [enabled, refresh]);
|
||||
|
||||
return { rooms, loading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LoadTimeline } from "../../wailsjs/go/main/MatrixService";
|
||||
import { EventsOn } from "../../wailsjs/runtime/runtime";
|
||||
import type { MatrixEvent, SyncEventView } from "../types";
|
||||
|
||||
/**
|
||||
* useMatrixTimeline loads the last `limit` events of a room (newest at the
|
||||
* bottom) and appends live events from the sync stream. Re-fetches whenever
|
||||
* roomID changes.
|
||||
*/
|
||||
export function useMatrixTimeline(roomID: string | null, limit = 50) {
|
||||
const [events, setEvents] = useState<MatrixEvent[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!roomID) {
|
||||
setEvents([]);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const list = await LoadTimeline(roomID, limit);
|
||||
// Mautrix Messages with DirectionBackward returns newest-first; flip to
|
||||
// oldest-first so React renders top-to-bottom chronologically.
|
||||
const ordered = [...(list || [])].reverse();
|
||||
setEvents(ordered);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
setError(String(e?.message ?? e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [roomID, limit]);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomID) return;
|
||||
const off = EventsOn("matrix:event", (ev: SyncEventView) => {
|
||||
if (ev?.room_id !== roomID) return;
|
||||
if (ev?.type !== "message" && ev?.type !== "encrypted") return;
|
||||
setEvents((prev) => {
|
||||
// Dedupe by event_id (sync may replay).
|
||||
if (prev.some((p) => p.event_id === ev.event_id)) return prev;
|
||||
const next: MatrixEvent = {
|
||||
event_id: ev.event_id,
|
||||
room_id: ev.room_id,
|
||||
sender: ev.sender,
|
||||
type: ev.type === "encrypted" ? "m.room.encrypted" : "m.room.message",
|
||||
ts: ev.ts,
|
||||
body: ev.body ?? "",
|
||||
encrypted_raw: ev.type === "encrypted",
|
||||
};
|
||||
return [...prev, next];
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
if (typeof off === "function") off();
|
||||
};
|
||||
}, [roomID]);
|
||||
|
||||
return { events, loading, error, refresh };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Local convenience aliases for the auto-generated Wails models.
|
||||
// We re-export the model classes/interfaces so the rest of the app imports
|
||||
// from one stable path instead of the long wailsjs/go/models.ts.
|
||||
|
||||
import { infra, main } from "../wailsjs/go/models";
|
||||
|
||||
export type RoomSummary = infra.RoomSummary;
|
||||
export type MatrixEvent = main.MatrixEvent;
|
||||
|
||||
// SyncEventView is what the Go runtime emits through "matrix:event".
|
||||
// Not part of the Wails-generated module (it never appears as a return type).
|
||||
export interface SyncEventView {
|
||||
type: string;
|
||||
room_id: string;
|
||||
event_id: string;
|
||||
sender: string;
|
||||
ts: number;
|
||||
body?: string;
|
||||
}
|
||||
@@ -2,13 +2,24 @@ module fn-registry/projects/element_agents/apps/matrix_client_pc
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require github.com/wailsapp/wails/v2 v2.11.0
|
||||
require (
|
||||
github.com/mattn/go-sqlite3 v1.14.44
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
github.com/wailsapp/wails/v2 v2.11.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
maunium.net/go/mautrix v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/aymerick/douceur v0.2.0 // indirect
|
||||
github.com/bep/debounce v1.2.1 // indirect
|
||||
github.com/danieljoos/wincred v1.2.3 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/css v1.0.1 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
|
||||
github.com/labstack/echo/v4 v4.13.3 // indirect
|
||||
@@ -19,17 +30,24 @@ require (
|
||||
github.com/leaanthony/u v1.1.1 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rs/zerolog v1.35.1 // indirect
|
||||
github.com/samber/lo v1.49.1 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/tidwall/gjson v1.19.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.1 // indirect
|
||||
github.com/tidwall/sjson v1.2.5 // indirect
|
||||
github.com/tkrajina/go-reflector v0.5.8 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasttemplate v1.2.2 // indirect
|
||||
github.com/wailsapp/go-webview2 v1.0.22 // indirect
|
||||
github.com/wailsapp/mimetype v1.4.1 // indirect
|
||||
go.mau.fi/util v0.9.9 // indirect
|
||||
golang.org/x/crypto v0.51.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
|
||||
golang.org/x/net v0.54.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
|
||||
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
|
||||
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
|
||||
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
|
||||
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
|
||||
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
@@ -8,6 +16,8 @@ github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
|
||||
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
|
||||
@@ -33,6 +43,12 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 h1:WDsQxOJDy0N1VRAjXLpi8sCEZRSGarLWQevDxpTBRrM=
|
||||
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
@@ -42,10 +58,24 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI=
|
||||
github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw=
|
||||
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
|
||||
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
|
||||
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
|
||||
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
@@ -58,8 +88,16 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw
|
||||
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
|
||||
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
|
||||
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.mau.fi/util v0.9.9 h1:ujDeXCo07HBor5oQLyO1tHklupmqVmPgasc53d7q/NE=
|
||||
go.mau.fi/util v0.9.9/go.mod h1:pqt4Vcrt+5gcH/CgrHZg11qSx+b34o6mknGzOEA6waY=
|
||||
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
|
||||
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a h1:+3jdDGGB8NGb1Zktc737jlt3/A5f6UlwSzmvqUuufxw=
|
||||
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
|
||||
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
|
||||
@@ -77,3 +115,5 @@ golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
maunium.net/go/mautrix v0.28.0 h1:vBakLzf8MAdfED3NzAKiMeKQbc3AQ4EAS03NC+TVMXQ=
|
||||
maunium.net/go/mautrix v0.28.0/go.mod h1:/a9A7LGaqb9B3nho4tLd28n0EPcCdwpm2dxkxkLLgh0=
|
||||
|
||||
@@ -13,10 +13,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func tempStoreDir() string {
|
||||
return filepath.Join(os.TempDir(), "matrix_client_pc_login")
|
||||
}
|
||||
|
||||
func userStoreDir(userID string) string {
|
||||
cfg, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
keyring "github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned by Load when no token exists for the given account.
|
||||
var ErrNotFound = errors.New("token not found in keyring")
|
||||
|
||||
// Token holds OAuth/OIDC credentials that need to survive app restarts.
|
||||
type Token struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token,omitempty"`
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = never expires
|
||||
UserID string `json:"user_id"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
HomeserverURL string `json:"homeserver_url"`
|
||||
Issuer string `json:"issuer,omitempty"` // MAS/OIDC issuer URL
|
||||
ClientID string `json:"client_id,omitempty"` // MAS client_id used
|
||||
// PickleKeyHex is the 32-byte hex-encoded key used by cryptohelper to
|
||||
// pickle Olm/Megolm sessions at-rest in the SQLite crypto store.
|
||||
// MUST persist across restarts. If lost, the crypto store is unusable
|
||||
// and a fresh device login is required.
|
||||
PickleKeyHex string `json:"pickle_key_hex,omitempty"`
|
||||
}
|
||||
|
||||
// KeyringTokenStore persists tokens in the OS keyring (Secret Service on Linux,
|
||||
// Keychain on macOS, Credential Manager on Windows).
|
||||
type KeyringTokenStore struct {
|
||||
// Service is the keyring namespace. Keep it stable across app versions.
|
||||
// Example: "fn_registry.matrix_client_pc"
|
||||
Service string
|
||||
}
|
||||
|
||||
// NewKeyringTokenStore returns a store scoped to the given service name.
|
||||
func NewKeyringTokenStore(service string) *KeyringTokenStore {
|
||||
return &KeyringTokenStore{Service: service}
|
||||
}
|
||||
|
||||
// Save serialises t to JSON and writes it to the keyring under (service, account).
|
||||
// Overwrites silently if an entry already exists.
|
||||
// account is typically the user ID, e.g. "@user:homeserver.example.com".
|
||||
func (s *KeyringTokenStore) Save(account string, t Token) error {
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring save: marshal: %w", err)
|
||||
}
|
||||
if err := keyring.Set(s.Service, account, string(b)); err != nil {
|
||||
return fmt.Errorf("keyring save: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load retrieves and deserialises the token stored under (service, account).
|
||||
// Returns ErrNotFound if no entry exists. Callers should check with errors.Is.
|
||||
func (s *KeyringTokenStore) Load(account string) (*Token, error) {
|
||||
raw, err := keyring.Get(s.Service, account)
|
||||
if err != nil {
|
||||
if errors.Is(err, keyring.ErrNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, fmt.Errorf("keyring load: %w", err)
|
||||
}
|
||||
var t Token
|
||||
if err := json.Unmarshal([]byte(raw), &t); err != nil {
|
||||
return nil, fmt.Errorf("keyring load: unmarshal: %w", err)
|
||||
}
|
||||
return &t, nil
|
||||
}
|
||||
|
||||
// Delete removes the token for account from the keyring.
|
||||
// Idempotent: if no entry exists, returns nil.
|
||||
func (s *KeyringTokenStore) Delete(account string) error {
|
||||
err := keyring.Delete(s.Service, account)
|
||||
if err != nil && !errors.Is(err, keyring.ErrNotFound) {
|
||||
return fmt.Errorf("keyring delete: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MasOidcLoopbackConfig configura el flujo OAuth2 PKCE con loopback HTTP
|
||||
// contra Matrix Authentication Service (MAS).
|
||||
type MasOidcLoopbackConfig struct {
|
||||
// Issuer es la URL base del MAS. Debe terminar en "/".
|
||||
// La funcion hace GET a {Issuer}.well-known/openid-configuration para descubrir endpoints.
|
||||
Issuer string
|
||||
|
||||
// ClientID es el ULID del client registrado en MAS.
|
||||
// El client debe tener client_auth_method: none (public client PKCE).
|
||||
ClientID string
|
||||
|
||||
// Scopes a solicitar. Si vacio usa ["openid", "urn:matrix:org.matrix.msc2967.client:api:*"].
|
||||
Scopes []string
|
||||
|
||||
// LoopbackPort es el puerto local donde escucha el callback.
|
||||
// Debe coincidir con el redirect_uri registrado en MAS (http://127.0.0.1:{port}/callback).
|
||||
// Si 0, elige un puerto libre dinamicamente.
|
||||
LoopbackPort int
|
||||
|
||||
// OpenBrowser abre el browser del SO automaticamente si es true.
|
||||
// Si false, imprime la URL a stdout y espera que el caller la abra.
|
||||
OpenBrowser bool
|
||||
|
||||
// TimeoutSeconds es el tiempo maximo esperando el callback. Default 300.
|
||||
TimeoutSeconds int
|
||||
}
|
||||
|
||||
// MasOidcLoopbackResult contiene los tokens devueltos por MAS tras el intercambio.
|
||||
type MasOidcLoopbackResult struct {
|
||||
// AccessToken es el Bearer token para usar contra Synapse.
|
||||
AccessToken string `json:"access_token"`
|
||||
|
||||
// RefreshToken permite renovar el access token sin re-autenticar.
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
|
||||
// ExpiresIn es el tiempo de vida del access token en segundos.
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
|
||||
// TokenType es el tipo de token, normalmente "Bearer".
|
||||
TokenType string `json:"token_type"`
|
||||
|
||||
// Scope es la lista de scopes concedidos (space-separated).
|
||||
Scope string `json:"scope"`
|
||||
|
||||
// IDToken es el JWT de identidad OIDC (puede estar vacio si no se pidio openid).
|
||||
IDToken string `json:"id_token,omitempty"`
|
||||
}
|
||||
|
||||
// oidcDiscovery es la respuesta de .well-known/openid-configuration.
|
||||
type oidcDiscovery struct {
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
}
|
||||
|
||||
// MasOidcLoopback ejecuta el flujo OAuth2 Authorization Code + PKCE contra MAS
|
||||
// usando un servidor HTTP loopback para recibir el callback.
|
||||
//
|
||||
// Flujo:
|
||||
// 1. Discovery de endpoints via .well-known/openid-configuration.
|
||||
// 2. Generacion de code_verifier/challenge PKCE y state anti-CSRF.
|
||||
// 3. Arranque de servidor loopback en 127.0.0.1:{LoopbackPort}.
|
||||
// 4. Apertura del browser (o impresion de URL si OpenBrowser=false).
|
||||
// 5. Espera del callback con el authorization code.
|
||||
// 6. Intercambio del code por tokens via POST al token_endpoint.
|
||||
// 7. Devolucion de MasOidcLoopbackResult.
|
||||
func MasOidcLoopback(cfg MasOidcLoopbackConfig) (*MasOidcLoopbackResult, error) {
|
||||
// 1. Validar inputs
|
||||
if cfg.Issuer == "" {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: Issuer no puede estar vacio")
|
||||
}
|
||||
if !strings.HasSuffix(cfg.Issuer, "/") {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: Issuer debe terminar en '/' (got %q)", cfg.Issuer)
|
||||
}
|
||||
if cfg.ClientID == "" {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: ClientID no puede estar vacio")
|
||||
}
|
||||
if cfg.LoopbackPort < 0 {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: LoopbackPort debe ser >= 0")
|
||||
}
|
||||
|
||||
timeout := time.Duration(cfg.TimeoutSeconds) * time.Second
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
timeout = 300 * time.Second
|
||||
}
|
||||
|
||||
scopes := cfg.Scopes
|
||||
if len(scopes) == 0 {
|
||||
scopes = []string{"openid", "urn:matrix:org.matrix.msc2967.client:api:*"}
|
||||
}
|
||||
|
||||
// 2. Discovery OIDC
|
||||
discovery, err := masOidcDiscover(cfg.Issuer)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: discovery failed: %w", err)
|
||||
}
|
||||
|
||||
// 3. PKCE: code_verifier + code_challenge
|
||||
verifier, challenge, err := masOidcPKCE()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: pkce generation failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. State anti-CSRF
|
||||
state, err := masOidcRandomBase64URL(32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: state generation failed: %w", err)
|
||||
}
|
||||
|
||||
// 5. Arrancar loopback server
|
||||
listener, port, err := masOidcStartListener(cfg.LoopbackPort)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: no se pudo abrir puerto loopback: %w", err)
|
||||
}
|
||||
|
||||
redirectURI := fmt.Sprintf("http://127.0.0.1:%d/callback", port)
|
||||
|
||||
// Canal para recibir el code o error desde el handler HTTP
|
||||
codeCh := make(chan string, 1)
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
|
||||
// Validar state anti-CSRF
|
||||
if q.Get("state") != state {
|
||||
errCh <- fmt.Errorf("mas_oidc_loopback: state mismatch (posible CSRF) — esperado %q, recibido %q", state, q.Get("state"))
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("<html><body><h2>Error: state mismatch. Por favor cierra esta ventana.</h2></body></html>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verificar error del proveedor
|
||||
if errParam := q.Get("error"); errParam != "" {
|
||||
desc := q.Get("error_description")
|
||||
errCh <- fmt.Errorf("mas_oidc_loopback: proveedor devolvio error %q: %s", errParam, desc)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte(fmt.Sprintf("<html><body><h2>Error de autorizacion: %s</h2></body></html>", desc)))
|
||||
return
|
||||
}
|
||||
|
||||
code := q.Get("code")
|
||||
if code == "" {
|
||||
errCh <- fmt.Errorf("mas_oidc_loopback: callback sin 'code'")
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
_, _ = w.Write([]byte("<html><body><h2>Error: no se recibio authorization code.</h2></body></html>"))
|
||||
return
|
||||
}
|
||||
|
||||
// Responder al browser con mensaje de exito
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head><meta charset="utf-8"><title>Login completo</title></head>
|
||||
<body style="font-family:sans-serif;text-align:center;padding:3em;">
|
||||
<h2>Login completo</h2>
|
||||
<p>Puedes cerrar esta ventana y volver a la aplicacion.</p>
|
||||
</body>
|
||||
</html>`))
|
||||
|
||||
codeCh <- code
|
||||
})
|
||||
|
||||
srv := &http.Server{Handler: mux}
|
||||
|
||||
// Arrancar el servidor en goroutine
|
||||
srvErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := srv.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||||
srvErrCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// 6. Construir URL de autorización
|
||||
authURL := masOidcBuildAuthURL(
|
||||
discovery.AuthorizationEndpoint,
|
||||
cfg.ClientID,
|
||||
redirectURI,
|
||||
strings.Join(scopes, " "),
|
||||
state,
|
||||
challenge,
|
||||
)
|
||||
|
||||
// 7. Abrir browser o imprimir URL
|
||||
if cfg.OpenBrowser {
|
||||
if err := masOidcOpenBrowser(authURL); err != nil {
|
||||
// No es fatal: continuamos y el usuario puede abrir manualmente
|
||||
fmt.Printf("mas_oidc_loopback: no se pudo abrir el browser automaticamente.\nAbre esta URL manualmente:\n%s\n", authURL)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("Abre esta URL en tu browser para autenticarte:\n%s\n", authURL)
|
||||
}
|
||||
|
||||
// 8. Esperar callback con timeout
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
var code string
|
||||
select {
|
||||
case code = <-codeCh:
|
||||
// ok
|
||||
case callbackErr := <-errCh:
|
||||
_ = srv.Shutdown(context.Background())
|
||||
return nil, callbackErr
|
||||
case <-ctx.Done():
|
||||
_ = srv.Shutdown(context.Background())
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: timeout esperando callback despues de %v", timeout)
|
||||
case srvErr := <-srvErrCh:
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: servidor loopback fallo: %w", srvErr)
|
||||
}
|
||||
|
||||
// 9. Shutdown graceful del servidor loopback
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer shutdownCancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
|
||||
// 10. Intercambiar code por tokens
|
||||
result, err := masOidcExchangeCode(
|
||||
discovery.TokenEndpoint,
|
||||
cfg.ClientID,
|
||||
code,
|
||||
redirectURI,
|
||||
verifier,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mas_oidc_loopback: token exchange failed: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// masOidcHTTPClient es el cliente HTTP usado por masOidcDiscover y masOidcExchangeCode.
|
||||
// Tiene timeout de 15s. Puede ser reemplazado en tests.
|
||||
var masOidcHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// masOidcDiscover obtiene los endpoints OIDC desde .well-known/openid-configuration.
|
||||
func masOidcDiscover(issuer string) (*oidcDiscovery, error) {
|
||||
discoveryURL := issuer + ".well-known/openid-configuration"
|
||||
resp, err := masOidcHTTPClient.Get(discoveryURL) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GET %s: %w", discoveryURL, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("discovery HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var d oidcDiscovery
|
||||
if err := json.NewDecoder(resp.Body).Decode(&d); err != nil {
|
||||
return nil, fmt.Errorf("parsing discovery JSON: %w", err)
|
||||
}
|
||||
if d.AuthorizationEndpoint == "" {
|
||||
return nil, fmt.Errorf("discovery: authorization_endpoint vacio")
|
||||
}
|
||||
if d.TokenEndpoint == "" {
|
||||
return nil, fmt.Errorf("discovery: token_endpoint vacio")
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// masOidcPKCE genera un code_verifier aleatorio y su code_challenge SHA256/base64url.
|
||||
func masOidcPKCE() (verifier, challenge string, err error) {
|
||||
verifier, err = masOidcRandomBase64URL(32) // 32 bytes -> 43 chars base64url
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
h := sha256.Sum256([]byte(verifier))
|
||||
challenge = base64.RawURLEncoding.EncodeToString(h[:])
|
||||
return verifier, challenge, nil
|
||||
}
|
||||
|
||||
// masOidcRandomBase64URL genera n bytes aleatorios codificados en base64url sin padding.
|
||||
func masOidcRandomBase64URL(n int) (string, error) {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// masOidcStartListener abre un listener TCP en 127.0.0.1:{port}.
|
||||
// Si port=0, elige un puerto libre y devuelve el puerto asignado.
|
||||
func masOidcStartListener(port int) (net.Listener, int, error) {
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
l, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
assignedPort := l.Addr().(*net.TCPAddr).Port
|
||||
return l, assignedPort, nil
|
||||
}
|
||||
|
||||
// masOidcBuildAuthURL construye la URL de autorización OAuth2 con PKCE.
|
||||
func masOidcBuildAuthURL(authEndpoint, clientID, redirectURI, scope, state, challenge string) string {
|
||||
u, _ := url.Parse(authEndpoint)
|
||||
q := u.Query()
|
||||
q.Set("response_type", "code")
|
||||
q.Set("client_id", clientID)
|
||||
q.Set("redirect_uri", redirectURI)
|
||||
q.Set("scope", scope)
|
||||
q.Set("state", state)
|
||||
q.Set("code_challenge", challenge)
|
||||
q.Set("code_challenge_method", "S256")
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// masOidcOpenBrowser abre la URL en el browser predeterminado del SO.
|
||||
func masOidcOpenBrowser(rawURL string) error {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
cmd = exec.Command("xdg-open", rawURL)
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", rawURL)
|
||||
case "windows":
|
||||
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", rawURL)
|
||||
default:
|
||||
return fmt.Errorf("plataforma no soportada para abrir browser: %s", runtime.GOOS)
|
||||
}
|
||||
return cmd.Start()
|
||||
}
|
||||
|
||||
// masOidcExchangeCode intercambia el authorization code por tokens via POST al token_endpoint.
|
||||
func masOidcExchangeCode(tokenEndpoint, clientID, code, redirectURI, verifier string) (*MasOidcLoopbackResult, error) {
|
||||
formData := url.Values{}
|
||||
formData.Set("grant_type", "authorization_code")
|
||||
formData.Set("code", code)
|
||||
formData.Set("redirect_uri", redirectURI)
|
||||
formData.Set("client_id", clientID)
|
||||
formData.Set("code_verifier", verifier)
|
||||
|
||||
resp, err := masOidcHTTPClient.PostForm(tokenEndpoint, formData) //nolint:gosec
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("POST %s: %w", tokenEndpoint, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("leyendo respuesta del token endpoint: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("token endpoint HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var result MasOidcLoopbackResult
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
return nil, fmt.Errorf("parsing token response JSON: %w", err)
|
||||
}
|
||||
if result.AccessToken == "" {
|
||||
return nil, fmt.Errorf("token response sin access_token: %s", string(body))
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// MatrixClientInitConfig parametriza la inicializacion del cliente Matrix.
|
||||
type MatrixClientInitConfig struct {
|
||||
// HomeserverURL es la URL base del servidor Matrix (Synapse/Dendrite/etc.).
|
||||
// Ejemplo: "https://matrix-af2f3d.organic-machine.com"
|
||||
HomeserverURL string
|
||||
|
||||
// UserID es el MXID del usuario. Formato "@local:servidor".
|
||||
// Ejemplo: "@egutierrez:matrix-af2f3d.organic-machine.com"
|
||||
UserID string
|
||||
|
||||
// AccessToken es el Bearer token obtenido del flow OIDC (mas_oidc_loopback).
|
||||
// No puede estar vacio.
|
||||
AccessToken string
|
||||
|
||||
// DeviceID del cliente Matrix. Si vacio, se descubre via /whoami al inicializar.
|
||||
// Recomendado guardarlo en keyring tras el primer uso para evitar la llamada extra.
|
||||
DeviceID string
|
||||
|
||||
// StoreDir es el directorio donde se persiste el estado de sync (next_batch, filter_id).
|
||||
// Se crea con permisos 0700 si no existe. Puede ser relativo (se convierte a absoluto).
|
||||
// Ejemplo: "~/.matrix_client_pc/egutierrez/" (no expandido automaticamente — usar os.UserHomeDir).
|
||||
StoreDir string
|
||||
|
||||
// EnableCrypto activa el crypto store SQLite para Olm/Megolm (E2EE).
|
||||
// En v0.1.0 devuelve error — la implementacion completa esta en issue 0150.
|
||||
EnableCrypto bool
|
||||
}
|
||||
|
||||
// MatrixClientInitResult contiene el cliente listo y los paths de persistencia.
|
||||
type MatrixClientInitResult struct {
|
||||
// Client es el *mautrix.Client listo para Sync/SendMessage.
|
||||
// UserID, AccessToken y DeviceID ya estan configurados.
|
||||
Client *mautrix.Client
|
||||
|
||||
// StorePath es la ruta al directorio de persistencia de sync state.
|
||||
StorePath string
|
||||
|
||||
// CryptoPath es la ruta calculada para el crypto store SQLite.
|
||||
// Vacio si EnableCrypto=false. En v0.1.0 siempre vacio (no implementado).
|
||||
CryptoPath string
|
||||
}
|
||||
|
||||
// MatrixClientInit construye un *mautrix.Client listo para hacer Sync,
|
||||
// sin manejar el login (que ya hizo el flow OIDC via mas_oidc_loopback).
|
||||
//
|
||||
// Pasos:
|
||||
// 1. Valida inputs (HomeserverURL parseable, UserID formato "@x:server", AccessToken no vacio).
|
||||
// 2. Crea StoreDir con permisos 0700.
|
||||
// 3. Llama mautrix.NewClient con las credenciales.
|
||||
// 4. Si DeviceID esta vacio, hace Whoami para descubrirlo (sum latency ~100ms).
|
||||
// 5. Si EnableCrypto=true, devuelve error (issue 0150 lo implementa).
|
||||
// 6. Devuelve MatrixClientInitResult con el cliente configurado.
|
||||
func MatrixClientInit(cfg MatrixClientInitConfig) (*MatrixClientInitResult, error) {
|
||||
// 1. Validar HomeserverURL
|
||||
if cfg.HomeserverURL == "" {
|
||||
return nil, fmt.Errorf("matrix_client_init: HomeserverURL no puede estar vacio")
|
||||
}
|
||||
if _, err := url.ParseRequestURI(cfg.HomeserverURL); err != nil {
|
||||
return nil, fmt.Errorf("matrix_client_init: HomeserverURL invalido %q: %w", cfg.HomeserverURL, err)
|
||||
}
|
||||
if !strings.HasPrefix(cfg.HomeserverURL, "http://") && !strings.HasPrefix(cfg.HomeserverURL, "https://") {
|
||||
return nil, fmt.Errorf("matrix_client_init: HomeserverURL debe empezar con http:// o https:// (got %q)", cfg.HomeserverURL)
|
||||
}
|
||||
|
||||
// Validar UserID: debe ser "@local:servidor"
|
||||
if cfg.UserID == "" {
|
||||
return nil, fmt.Errorf("matrix_client_init: UserID no puede estar vacio")
|
||||
}
|
||||
if !strings.HasPrefix(cfg.UserID, "@") || !strings.Contains(cfg.UserID, ":") {
|
||||
return nil, fmt.Errorf("matrix_client_init: UserID invalido %q — formato esperado @local:servidor", cfg.UserID)
|
||||
}
|
||||
|
||||
// Validar AccessToken
|
||||
if cfg.AccessToken == "" {
|
||||
return nil, fmt.Errorf("matrix_client_init: AccessToken no puede estar vacio")
|
||||
}
|
||||
|
||||
// Validar StoreDir
|
||||
if cfg.StoreDir == "" {
|
||||
return nil, fmt.Errorf("matrix_client_init: StoreDir no puede estar vacio")
|
||||
}
|
||||
|
||||
// En v0.1.0 crypto no esta implementado
|
||||
if cfg.EnableCrypto {
|
||||
return nil, fmt.Errorf("matrix_client_init: crypto not implemented in v0.1.0, see issue 0150")
|
||||
}
|
||||
|
||||
// Convertir StoreDir a absoluto si es relativo
|
||||
storeDir := cfg.StoreDir
|
||||
if !filepath.IsAbs(storeDir) {
|
||||
abs, err := filepath.Abs(storeDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_client_init: no se pudo resolver StoreDir %q: %w", storeDir, err)
|
||||
}
|
||||
storeDir = abs
|
||||
}
|
||||
|
||||
// 2. Crear StoreDir con permisos 0700 (datos sensibles)
|
||||
if err := os.MkdirAll(storeDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("matrix_client_init: no se pudo crear StoreDir %q: %w", storeDir, err)
|
||||
}
|
||||
|
||||
// 3. Construir cliente mautrix
|
||||
client, err := mautrix.NewClient(cfg.HomeserverURL, id.UserID(cfg.UserID), cfg.AccessToken)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_client_init: mautrix.NewClient failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. DeviceID: usar el proporcionado o descubrir via Whoami
|
||||
if cfg.DeviceID != "" {
|
||||
client.DeviceID = id.DeviceID(cfg.DeviceID)
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
whoami, err := client.Whoami(ctx)
|
||||
if err != nil {
|
||||
// Distinguir token invalido (M_UNKNOWN_TOKEN) de error de red
|
||||
if errors.Is(err, mautrix.MUnknownToken) {
|
||||
return nil, fmt.Errorf("matrix_client_init: access token invalido o expirado (M_UNKNOWN_TOKEN) — refrescar via OIDC: %w", err)
|
||||
}
|
||||
return nil, fmt.Errorf("matrix_client_init: Whoami failed (servidor caido o token invalido): %w", err)
|
||||
}
|
||||
client.DeviceID = whoami.DeviceID
|
||||
}
|
||||
|
||||
// Calcular CryptoPath (aunque no se use en v0.1.0)
|
||||
cryptoPath := ""
|
||||
// CryptoPath calculado pero no inicializado en v0.1.0
|
||||
_ = filepath.Join(storeDir, "crypto.db") // reservado para matrix_crypto_init_go_infra (issue 0150)
|
||||
|
||||
return &MatrixClientInitResult{
|
||||
Client: client,
|
||||
StorePath: storeDir,
|
||||
CryptoPath: cryptoPath,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
//go:build goolm || libolm
|
||||
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||
)
|
||||
|
||||
// MatrixCryptoInitConfig parametriza la inicializacion del crypto store Olm/Megolm.
|
||||
type MatrixCryptoInitConfig struct {
|
||||
// Client es el *mautrix.Client ya inicializado via MatrixClientInit.
|
||||
// Debe tener AccessToken, UserID y DeviceID poblados.
|
||||
Client *mautrix.Client
|
||||
|
||||
// StorePath es la ruta absoluta al archivo SQLite del crypto store.
|
||||
// Debe ser separado del state store. El SDK gestiona el schema internamente.
|
||||
// Si el directorio padre no existe, se crea con permisos 0700.
|
||||
// Ejemplo: "/home/lucas/.config/matrix_client_pc/egutierrez/crypto.db"
|
||||
StorePath string
|
||||
|
||||
// PickleKey son exactamente 32 bytes usados por cryptohelper para cifrar las
|
||||
// sesiones Olm en disco at-rest. DEBE persistir entre arranques (guardar en keyring).
|
||||
// Si se pierde, el store SQLite se vuelve inutilizable y hay que crear nuevo dispositivo.
|
||||
PickleKey []byte
|
||||
}
|
||||
|
||||
// MatrixCryptoInitResult contiene el helper listo para usar.
|
||||
type MatrixCryptoInitResult struct {
|
||||
// Helper es el *cryptohelper.CryptoHelper inicializado.
|
||||
// Ya esta asignado a client.Crypto — el Sync loop cifra/descifra automaticamente.
|
||||
Helper *cryptohelper.CryptoHelper
|
||||
|
||||
// StorePath es la ruta al archivo SQLite del crypto store (igual que cfg.StorePath).
|
||||
StorePath string
|
||||
}
|
||||
|
||||
// MatrixCryptoInit inicializa el crypto store Olm/Megolm para un cliente mautrix
|
||||
// usando cryptohelper — el wrapper oficial que abstrae SQLite + Olm identity keys +
|
||||
// one-time key upload + decrypt automatico via el Syncer.
|
||||
//
|
||||
// Pasos:
|
||||
// 1. Valida inputs (Client no nil con AccessToken/UserID/DeviceID, StorePath
|
||||
// absoluto, PickleKey exactamente 32 bytes).
|
||||
// 2. Crea el directorio padre de StorePath con permisos 0700 si no existe.
|
||||
// 3. Construye el helper via cryptohelper.NewCryptoHelper(client, pickleKey, storePath).
|
||||
// 4. Llama helper.Init(ctx) — crea tablas SQLite, carga cuenta Olm, sube one-time keys.
|
||||
// 5. Asigna client.Crypto = helper para que SendMessageEvent cifre automaticamente.
|
||||
// 6. Devuelve MatrixCryptoInitResult con el helper listo.
|
||||
func MatrixCryptoInit(ctx context.Context, cfg MatrixCryptoInitConfig) (*MatrixCryptoInitResult, error) {
|
||||
// 1. Validar Client
|
||||
if cfg.Client == nil {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: Client no puede ser nil")
|
||||
}
|
||||
if cfg.Client.AccessToken == "" {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: Client.AccessToken no puede estar vacio")
|
||||
}
|
||||
if cfg.Client.UserID == "" {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: Client.UserID no puede estar vacio")
|
||||
}
|
||||
if cfg.Client.DeviceID == "" {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: Client.DeviceID no puede estar vacio — descubrirlo via MatrixClientInit o Whoami antes de llamar MatrixCryptoInit")
|
||||
}
|
||||
|
||||
// Validar StorePath
|
||||
if cfg.StorePath == "" {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: StorePath no puede estar vacio")
|
||||
}
|
||||
if !filepath.IsAbs(cfg.StorePath) {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: StorePath debe ser una ruta absoluta (got %q)", cfg.StorePath)
|
||||
}
|
||||
|
||||
// Validar PickleKey: exactamente 32 bytes
|
||||
if len(cfg.PickleKey) != 32 {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: PickleKey debe tener exactamente 32 bytes (got %d)", len(cfg.PickleKey))
|
||||
}
|
||||
|
||||
// 2. Crear directorio padre con permisos 0700 (datos sensibles)
|
||||
storeDir := filepath.Dir(cfg.StorePath)
|
||||
if err := os.MkdirAll(storeDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: no se pudo crear directorio del store %q: %w", storeDir, err)
|
||||
}
|
||||
|
||||
// 3. Construir CryptoHelper — acepta string como path SQLite directamente (v0.28 API)
|
||||
helper, err := cryptohelper.NewCryptoHelper(cfg.Client, cfg.PickleKey, cfg.StorePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: NewCryptoHelper failed: %w", err)
|
||||
}
|
||||
|
||||
// 4. Init: crea tablas SQLite, carga cuenta Olm, sube one-time keys al servidor
|
||||
if err := helper.Init(ctx); err != nil {
|
||||
return nil, fmt.Errorf("matrix_crypto_init: helper.Init failed (comprueba conectividad con Synapse y validez del token): %w", err)
|
||||
}
|
||||
|
||||
// 5. Asignar client.Crypto para que SendMessageEvent cifre automaticamente
|
||||
cfg.Client.Crypto = helper
|
||||
|
||||
return &MatrixCryptoInitResult{
|
||||
Helper: helper,
|
||||
StorePath: cfg.StorePath,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/yuin/goldmark"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// matrixMarkdownToHTML convierte Markdown a HTML sanitizado con goldmark + bluemonday.
|
||||
// El HTML resultante es seguro para incluir en formatted_body de un evento Matrix.
|
||||
// Allowlist: bluemonday UGCPolicy + <details>, <summary>, <code>, <pre>.
|
||||
func matrixMarkdownToHTML(markdown string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := goldmark.Convert([]byte(markdown), &buf); err != nil {
|
||||
return "", fmt.Errorf("matrix_message_send: goldmark convert: %w", err)
|
||||
}
|
||||
p := bluemonday.UGCPolicy()
|
||||
p.AllowElements("details", "summary", "code", "pre")
|
||||
sanitized := p.SanitizeBytes(buf.Bytes())
|
||||
return string(sanitized), nil
|
||||
}
|
||||
|
||||
// matrixSendEvent es el helper interno que llama a client.SendMessageEvent
|
||||
// y devuelve el id.EventID asignado por Synapse.
|
||||
func matrixSendEvent(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventType event.Type, content interface{}) (id.EventID, error) {
|
||||
resp, err := client.SendMessageEvent(ctx, roomID, eventType, content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.EventID, nil
|
||||
}
|
||||
|
||||
// MatrixSendText envía un mensaje de texto plano (m.text) al room indicado.
|
||||
// Si el room tiene E2EE activo y client.Crypto != nil, mautrix cifra automáticamente.
|
||||
func MatrixSendText(ctx context.Context, client *mautrix.Client, roomID id.RoomID, body string) (id.EventID, error) {
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: body,
|
||||
}
|
||||
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
|
||||
}
|
||||
|
||||
// MatrixSendMarkdown convierte markdown a HTML con goldmark, lo sanitiza con bluemonday
|
||||
// (UGCPolicy + <details>, <summary>, <code>, <pre>) y envía con format=org.matrix.custom.html.
|
||||
// El campo Body contiene el markdown original como fallback para clientes sin HTML.
|
||||
func MatrixSendMarkdown(ctx context.Context, client *mautrix.Client, roomID id.RoomID, markdown string) (id.EventID, error) {
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
|
||||
}
|
||||
htmlBody, err := matrixMarkdownToHTML(markdown)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("matrix_message_send.MatrixSendMarkdown: %w", err)
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: markdown,
|
||||
Format: event.FormatHTML,
|
||||
FormattedBody: htmlBody,
|
||||
}
|
||||
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
|
||||
}
|
||||
|
||||
// MatrixSendReply envía un mensaje con m.relates_to.m.in_reply_to apuntando a replyTo.
|
||||
// El body es el texto de la respuesta. En v0.1.0 el caller construye la cita si la necesita.
|
||||
// El cifrado E2EE es automático si client.Crypto está configurado.
|
||||
func MatrixSendReply(ctx context.Context, client *mautrix.Client, roomID id.RoomID, replyTo id.EventID, body string) (id.EventID, error) {
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: body,
|
||||
RelatesTo: (&event.RelatesTo{}).SetReplyTo(replyTo),
|
||||
}
|
||||
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
|
||||
}
|
||||
|
||||
// MatrixEditMessage envía un replacement event (m.replace) compatible con Element y la spec Matrix.
|
||||
// NewContent contiene el texto nuevo; Body es el fallback "* newBody" para clientes sin soporte de edición.
|
||||
// eventID es el evento original a reemplazar.
|
||||
func MatrixEditMessage(ctx context.Context, client *mautrix.Client, roomID id.RoomID, eventID id.EventID, newBody string) (id.EventID, error) {
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
|
||||
}
|
||||
content := &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: "* " + newBody,
|
||||
NewContent: &event.MessageEventContent{
|
||||
MsgType: event.MsgText,
|
||||
Body: newBody,
|
||||
},
|
||||
RelatesTo: (&event.RelatesTo{}).SetReplace(eventID),
|
||||
}
|
||||
return matrixSendEvent(ctx, client, roomID, event.EventMessage, content)
|
||||
}
|
||||
|
||||
// MatrixSendReaction envía un evento m.reaction con m.relates_to.rel_type=m.annotation.
|
||||
// key debe ser el emoji unicode raw (ej. "👍"), no shortcode (:thumbsup:).
|
||||
// Las reactions no se cifran aunque el room sea E2EE (comportamiento de mautrix-go).
|
||||
func MatrixSendReaction(ctx context.Context, client *mautrix.Client, roomID id.RoomID, targetEventID id.EventID, key string) (id.EventID, error) {
|
||||
if client == nil {
|
||||
return "", fmt.Errorf("matrix_message_send: client no puede ser nil")
|
||||
}
|
||||
content := &event.ReactionEventContent{
|
||||
RelatesTo: event.RelatesTo{
|
||||
Type: event.RelAnnotation,
|
||||
EventID: targetEventID,
|
||||
Key: key,
|
||||
},
|
||||
}
|
||||
return matrixSendEvent(ctx, client, roomID, event.EventReaction, content)
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// RoomSummary es el resumen de una room Matrix para renderizar en el sidebar de un cliente.
|
||||
type RoomSummary struct {
|
||||
RoomID string `json:"room_id"`
|
||||
Name string `json:"name,omitempty"` // m.room.name o fallback
|
||||
CanonicalAlias string `json:"canonical_alias,omitempty"` // #room:server
|
||||
AvatarMxc string `json:"avatar_mxc,omitempty"` // mxc://...
|
||||
Topic string `json:"topic,omitempty"`
|
||||
IsDirect bool `json:"is_direct"` // m.direct account_data
|
||||
IsSpace bool `json:"is_space"` // m.room.type == m.space
|
||||
IsEncrypted bool `json:"is_encrypted"` // m.room.encryption state event presente
|
||||
MemberCount int `json:"member_count"`
|
||||
LastEventTs int64 `json:"last_event_ts"` // unix ms del ultimo evento conocido
|
||||
UnreadCount int `json:"unread_count"` // notifications.unread + highlight
|
||||
Tags []string `json:"tags,omitempty"` // m.tag account_data
|
||||
}
|
||||
|
||||
// MatrixRoomListConfig agrupa los parametros de MatrixRoomList.
|
||||
type MatrixRoomListConfig struct {
|
||||
Client *mautrix.Client
|
||||
}
|
||||
|
||||
// MatrixRoomList devuelve todos los rooms en los que el usuario esta unido,
|
||||
// ordenados por LastEventTs DESC (recientes primero).
|
||||
//
|
||||
// Estrategia:
|
||||
// 1. JoinedRooms() para la lista de room IDs.
|
||||
// 2. m.direct account_data para detectar DMs.
|
||||
// 3. Para cada room: State() -> nombre, alias, topic, avatar, encryption, space, members.
|
||||
// 4. Messages(limit=1) -> LastEventTs (TODO: coste N*HTTP; cachear con TTL 30s).
|
||||
// 5. GetRoomAccountData("m.tag") -> Tags.
|
||||
//
|
||||
// Sub-operaciones que fallan por room concreto no abortan el global.
|
||||
// LastEventTs puede ser 0 si el store no lo cachea (ver ## Gotchas del .md).
|
||||
func MatrixRoomList(ctx context.Context, cfg MatrixRoomListConfig) ([]RoomSummary, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, fmt.Errorf("matrix_room_list: client no puede ser nil")
|
||||
}
|
||||
client := cfg.Client
|
||||
|
||||
// 1. Rooms unidos
|
||||
respJoined, err := client.JoinedRooms(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("matrix_room_list: JoinedRooms: %w", err)
|
||||
}
|
||||
if len(respJoined.JoinedRooms) == 0 {
|
||||
return []RoomSummary{}, nil
|
||||
}
|
||||
|
||||
// 2. m.direct -> set roomID -> true
|
||||
directSet := loadDirectRooms(ctx, client)
|
||||
|
||||
// 3. Construir summaries (secuencial para v0.1.0)
|
||||
results := make([]RoomSummary, 0, len(respJoined.JoinedRooms))
|
||||
for _, roomID := range respJoined.JoinedRooms {
|
||||
s := buildRoomSummaryFromState(ctx, client, roomID, directSet)
|
||||
results = append(results, s)
|
||||
}
|
||||
|
||||
// 4. Ordenar DESC por LastEventTs; si empatan (ej. todo 0) -> alfabetico por Name
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
if results[i].LastEventTs != results[j].LastEventTs {
|
||||
return results[i].LastEventTs > results[j].LastEventTs
|
||||
}
|
||||
return results[i].Name < results[j].Name
|
||||
})
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// loadDirectRooms carga m.direct account_data y devuelve un set roomID -> true.
|
||||
// Falla silenciosamente: si hay error devuelve mapa vacio (IsDirect quedara false).
|
||||
func loadDirectRooms(ctx context.Context, client *mautrix.Client) map[id.RoomID]bool {
|
||||
result := make(map[id.RoomID]bool)
|
||||
var directContent event.DirectChatsEventContent
|
||||
if err := client.GetAccountData(ctx, "m.direct", &directContent); err != nil {
|
||||
log.Printf("matrix_room_list: GetAccountData(m.direct) warning: %v", err)
|
||||
return result
|
||||
}
|
||||
for _, rooms := range directContent {
|
||||
for _, rid := range rooms {
|
||||
result[rid] = true
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildRoomSummaryFromState construye el RoomSummary para un room concreto.
|
||||
// Si State() falla usa el roomID como Name de emergencia.
|
||||
func buildRoomSummaryFromState(ctx context.Context, client *mautrix.Client, roomID id.RoomID, directSet map[id.RoomID]bool) RoomSummary {
|
||||
s := RoomSummary{
|
||||
RoomID: string(roomID),
|
||||
IsDirect: directSet[roomID],
|
||||
}
|
||||
|
||||
// State del room
|
||||
stateMap, err := client.State(ctx, roomID)
|
||||
if err != nil {
|
||||
log.Printf("matrix_room_list: State(%s) warning: %v", roomID, err)
|
||||
s.Name = deriveRoomName(&s, nil)
|
||||
return s
|
||||
}
|
||||
|
||||
fillStateFields(&s, stateMap)
|
||||
s.Name = deriveRoomName(&s, stateMap)
|
||||
|
||||
// Tags: m.tag room account_data
|
||||
s.Tags = loadRoomTags(ctx, client, roomID)
|
||||
|
||||
// LastEventTs: Messages(limit=1, dir=backward)
|
||||
// TODO(0148): caro N*HTTP -> cachear en backend con TTL 30s.
|
||||
msgs, err := client.Messages(ctx, roomID, "", "", mautrix.DirectionBackward, nil, 1)
|
||||
if err != nil {
|
||||
log.Printf("matrix_room_list: Messages(%s) warning: %v", roomID, err)
|
||||
// No fatal: LastEventTs queda 0 y el room cae al fondo del orden
|
||||
} else if msgs != nil && len(msgs.Chunk) > 0 {
|
||||
s.LastEventTs = msgs.Chunk[0].Timestamp
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// ensureParsed llama ParseRaw si el contenido no esta aun parseado.
|
||||
// ParseRaw devuelve ErrContentAlreadyParsed cuando ya fue parseado (p.ej.
|
||||
// por parseRoomStateArray al deserializar el state); en ese caso ignoramos
|
||||
// el error y usamos el Parsed existente.
|
||||
func ensureParsed(c *event.Content, evtType event.Type) {
|
||||
if c.Parsed == nil {
|
||||
_ = c.ParseRaw(evtType)
|
||||
}
|
||||
}
|
||||
|
||||
// fillStateFields rellena los campos del RoomSummary a partir del state map.
|
||||
// parseRoomStateArray ya llama ParseRaw al deserializar, por lo que es posible
|
||||
// que Content.Parsed este ya populado. ensureParsed maneja ambos casos.
|
||||
func fillStateFields(s *RoomSummary, stateMap mautrix.RoomStateMap) {
|
||||
// m.room.name
|
||||
if nameEvts, ok := stateMap[event.StateRoomName]; ok {
|
||||
if nameEvt, ok := nameEvts[""]; ok {
|
||||
ensureParsed(&nameEvt.Content, event.StateRoomName)
|
||||
if c := nameEvt.Content.AsRoomName(); c != nil {
|
||||
s.Name = c.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// m.room.canonical_alias
|
||||
if aliasEvts, ok := stateMap[event.StateCanonicalAlias]; ok {
|
||||
if aliasEvt, ok := aliasEvts[""]; ok {
|
||||
ensureParsed(&aliasEvt.Content, event.StateCanonicalAlias)
|
||||
if c := aliasEvt.Content.AsCanonicalAlias(); c != nil {
|
||||
s.CanonicalAlias = string(c.Alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// m.room.avatar
|
||||
if avatarEvts, ok := stateMap[event.StateRoomAvatar]; ok {
|
||||
if avatarEvt, ok := avatarEvts[""]; ok {
|
||||
ensureParsed(&avatarEvt.Content, event.StateRoomAvatar)
|
||||
if c := avatarEvt.Content.AsRoomAvatar(); c != nil {
|
||||
s.AvatarMxc = string(c.URL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// m.room.topic
|
||||
if topicEvts, ok := stateMap[event.StateTopic]; ok {
|
||||
if topicEvt, ok := topicEvts[""]; ok {
|
||||
ensureParsed(&topicEvt.Content, event.StateTopic)
|
||||
if c := topicEvt.Content.AsTopic(); c != nil {
|
||||
s.Topic = c.Topic
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// m.room.encryption (existence = encrypted)
|
||||
if encEvts, ok := stateMap[event.StateEncryption]; ok {
|
||||
if _, ok := encEvts[""]; ok {
|
||||
s.IsEncrypted = true
|
||||
}
|
||||
}
|
||||
|
||||
// m.room.create -> IsSpace si type == "m.space"
|
||||
if createEvts, ok := stateMap[event.StateCreate]; ok {
|
||||
if createEvt, ok := createEvts[""]; ok {
|
||||
ensureParsed(&createEvt.Content, event.StateCreate)
|
||||
if c := createEvt.Content.AsCreate(); c != nil {
|
||||
s.IsSpace = c.Type == event.RoomTypeSpace
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// m.room.member: contar membership == join
|
||||
if memberEvts, ok := stateMap[event.StateMember]; ok {
|
||||
count := 0
|
||||
for _, memberEvt := range memberEvts {
|
||||
ensureParsed(&memberEvt.Content, event.StateMember)
|
||||
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
|
||||
count++
|
||||
}
|
||||
}
|
||||
s.MemberCount = count
|
||||
}
|
||||
}
|
||||
|
||||
// deriveRoomName calcula el nombre display para el room siguiendo la jerarquia:
|
||||
// 1. Name (ya seteado desde m.room.name).
|
||||
// 2. CanonicalAlias.
|
||||
// 3. "Direct Message" si IsDirect.
|
||||
// 4. Lista de otros miembros si los hay (max 3).
|
||||
// 5. "Empty room" si MemberCount <= 1.
|
||||
func deriveRoomName(s *RoomSummary, stateMap mautrix.RoomStateMap) string {
|
||||
if s.Name != "" {
|
||||
return s.Name
|
||||
}
|
||||
if s.CanonicalAlias != "" {
|
||||
return s.CanonicalAlias
|
||||
}
|
||||
if s.IsDirect {
|
||||
// Intentar obtener displayname del otro miembro desde el state
|
||||
if stateMap != nil {
|
||||
if memberEvts, ok := stateMap[event.StateMember]; ok {
|
||||
for userKey, memberEvt := range memberEvts {
|
||||
ensureParsed(&memberEvt.Content, event.StateMember)
|
||||
if c := memberEvt.Content.AsMember(); c != nil &&
|
||||
c.Membership == event.MembershipJoin &&
|
||||
userKey != "" {
|
||||
if c.Displayname != "" {
|
||||
return c.Displayname
|
||||
}
|
||||
return userKey // user ID como fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "Direct Message"
|
||||
}
|
||||
if stateMap != nil && s.MemberCount > 1 {
|
||||
// Lista de displaynames de otros miembros (max 3)
|
||||
names := collectMemberNames(stateMap, 3)
|
||||
if len(names) > 0 {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
}
|
||||
return "Empty room"
|
||||
}
|
||||
|
||||
// collectMemberNames extrae hasta maxN displaynames de joined members del state.
|
||||
func collectMemberNames(stateMap mautrix.RoomStateMap, maxN int) []string {
|
||||
names := make([]string, 0, maxN)
|
||||
if memberEvts, ok := stateMap[event.StateMember]; ok {
|
||||
for userKey, memberEvt := range memberEvts {
|
||||
if len(names) >= maxN {
|
||||
break
|
||||
}
|
||||
ensureParsed(&memberEvt.Content, event.StateMember)
|
||||
if c := memberEvt.Content.AsMember(); c != nil && c.Membership == event.MembershipJoin {
|
||||
if c.Displayname != "" {
|
||||
names = append(names, c.Displayname)
|
||||
} else if userKey != "" {
|
||||
names = append(names, userKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// loadRoomTags carga m.tag room account_data y devuelve los tag names como []string.
|
||||
// Falla silenciosamente devolviendo nil.
|
||||
func loadRoomTags(ctx context.Context, client *mautrix.Client, roomID id.RoomID) []string {
|
||||
var tagContent event.TagEventContent
|
||||
if err := client.GetRoomAccountData(ctx, roomID, "m.tag", &tagContent); err != nil {
|
||||
// No fatal: rooms sin tags dan 404, lo cual es normal
|
||||
return nil
|
||||
}
|
||||
if len(tagContent.Tags) == 0 {
|
||||
return nil
|
||||
}
|
||||
tags := make([]string, 0, len(tagContent.Tags))
|
||||
for tag := range tagContent.Tags {
|
||||
tags = append(tags, string(tag))
|
||||
}
|
||||
sort.Strings(tags) // orden determinista
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
package infra
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// MatrixSyncEvent es un evento normalizado emitido por MatrixSyncService.
|
||||
// Cubre mensajes, pertenencia a sala, redacciones, reacciones, tipeo y estado.
|
||||
type MatrixSyncEvent struct {
|
||||
Type string `json:"type"` // "message" | "membership" | "redaction" | "reaction" | "edit" | "encrypted" | "presence" | "typing" | "read_receipt" | "room_state"
|
||||
RoomID string `json:"room_id"` // ID de la sala (vacio para presencia global)
|
||||
EventID string `json:"event_id"` // event_id unico Matrix (vacio para eventos efimeros)
|
||||
Sender string `json:"sender"` // MXID del emisor (vacio para eventos efimeros)
|
||||
Ts int64 `json:"ts"` // origin_server_ts en milisegundos
|
||||
Body string `json:"body,omitempty"` // contenido de texto del evento (mensajes)
|
||||
Raw interface{} `json:"raw,omitempty"` // *event.Event original para acceso completo
|
||||
}
|
||||
|
||||
// MatrixSyncServiceConfig configura el servicio de sync loop de Matrix.
|
||||
type MatrixSyncServiceConfig struct {
|
||||
// Client es el *mautrix.Client ya inicializado con credenciales.
|
||||
// Obligatorio.
|
||||
Client *mautrix.Client
|
||||
|
||||
// InitialBackoffMS es el tiempo inicial de espera entre reintentos tras error (ms).
|
||||
// Default: 1000 (1 segundo).
|
||||
InitialBackoffMS int
|
||||
|
||||
// MaxBackoffMS es el techo del backoff exponencial (ms).
|
||||
// Default: 60000 (60 segundos).
|
||||
MaxBackoffMS int
|
||||
|
||||
// ChannelBuffer es la capacidad del canal Events.
|
||||
// Si el consumer va lento y el buffer se llena, el sync se bloquea hasta
|
||||
// que el consumer drene. Default: 256.
|
||||
ChannelBuffer int
|
||||
}
|
||||
|
||||
// MatrixSyncServiceHandle es el handle devuelto por MatrixSyncService.
|
||||
type MatrixSyncServiceHandle struct {
|
||||
// Events es el canal de eventos normalizados (cierra al Stop).
|
||||
Events <-chan MatrixSyncEvent
|
||||
|
||||
// Errors recibe errores transitorios (red, 5xx, etc.).
|
||||
// No fatal: el servicio reintenta con backoff. El caller decide si actuar.
|
||||
// El canal cierra al Stop.
|
||||
Errors <-chan error
|
||||
|
||||
// Stop cancela el sync loop de forma limpia e idempotente.
|
||||
// Cierra Events y Errors. Seguro llamar varias veces.
|
||||
Stop func()
|
||||
}
|
||||
|
||||
// matrixSyncerWrapper envuelve DefaultSyncer para interceptar OnFailedSync
|
||||
// e inyectar nuestro backoff exponencial y emision de errores al canal.
|
||||
type matrixSyncerWrapper struct {
|
||||
*mautrix.DefaultSyncer
|
||||
errCh chan<- error
|
||||
innerCtx context.Context
|
||||
backoffMs *int
|
||||
initialMS int
|
||||
maxMS int
|
||||
lastSyncOK *time.Time
|
||||
}
|
||||
|
||||
// OnFailedSync implementa mautrix.Syncer. Emite el error al canal y devuelve
|
||||
// el proximo backoff. Para errores fatales (401, M_FORBIDDEN) devuelve el
|
||||
// backoff maximo y emite al canal — el caller decide via Stop().
|
||||
func (w *matrixSyncerWrapper) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
|
||||
if w.innerCtx.Err() != nil {
|
||||
return 0, fmt.Errorf("matrix_sync_service: context cancelado")
|
||||
}
|
||||
|
||||
// Emitir error al canal de forma no-bloqueante
|
||||
select {
|
||||
case w.errCh <- fmt.Errorf("matrix_sync_service: %w", err):
|
||||
default:
|
||||
}
|
||||
|
||||
// Reset backoff si el ultimo sync exitoso fue reciente
|
||||
if time.Since(*w.lastSyncOK) < 30*time.Second {
|
||||
*w.backoffMs = w.initialMS
|
||||
}
|
||||
|
||||
// Calcular duracion de espera
|
||||
wait := time.Duration(*w.backoffMs) * time.Millisecond
|
||||
|
||||
// Backoff exponencial con techo
|
||||
*w.backoffMs *= 2
|
||||
if *w.backoffMs > w.maxMS {
|
||||
*w.backoffMs = w.maxMS
|
||||
}
|
||||
|
||||
// Para errores fatales, esperar el maximo pero no retornar error
|
||||
// (dejamos al caller decidir via Stop)
|
||||
if isFatalMatrixError(err) {
|
||||
return time.Duration(w.maxMS) * time.Millisecond, nil
|
||||
}
|
||||
|
||||
return wait, nil
|
||||
}
|
||||
|
||||
// GetFilterJSON delega al DefaultSyncer.
|
||||
func (w *matrixSyncerWrapper) GetFilterJSON(userID id.UserID) *mautrix.Filter {
|
||||
return w.DefaultSyncer.GetFilterJSON(userID)
|
||||
}
|
||||
|
||||
// ProcessResponse delega al DefaultSyncer. Actualiza lastSyncOK en exito.
|
||||
func (w *matrixSyncerWrapper) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
|
||||
err := w.DefaultSyncer.ProcessResponse(ctx, resp, since)
|
||||
if err == nil {
|
||||
now := time.Now()
|
||||
*w.lastSyncOK = now
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MatrixSyncService arranca el sync loop de mautrix contra Synapse en background.
|
||||
// Registra handlers para los tipos de evento mas comunes y los emite via canal.
|
||||
// Implementa reconnect con backoff exponencial para errores transitorios.
|
||||
//
|
||||
// Requiere un *mautrix.Client ya inicializado (ver matrix_client_init).
|
||||
// Opcionalmente combinar con matrix_crypto_init para descifrar m.room.encrypted.
|
||||
//
|
||||
// La goroutine interna vive hasta que ctx sea cancelado o se llame Stop.
|
||||
// Ambas acciones cierran los canales Events y Errors.
|
||||
func MatrixSyncService(ctx context.Context, cfg MatrixSyncServiceConfig) (*MatrixSyncServiceHandle, error) {
|
||||
if cfg.Client == nil {
|
||||
return nil, fmt.Errorf("matrix_sync_service: Client no puede ser nil")
|
||||
}
|
||||
|
||||
// Aplicar defaults
|
||||
initialBackoff := cfg.InitialBackoffMS
|
||||
if initialBackoff <= 0 {
|
||||
initialBackoff = 1000
|
||||
}
|
||||
maxBackoff := cfg.MaxBackoffMS
|
||||
if maxBackoff <= 0 {
|
||||
maxBackoff = 60000
|
||||
}
|
||||
bufSize := cfg.ChannelBuffer
|
||||
if bufSize <= 0 {
|
||||
bufSize = 256
|
||||
}
|
||||
|
||||
// Context cancelable derivado del pasado
|
||||
innerCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// Channels
|
||||
evtCh := make(chan MatrixSyncEvent, bufSize)
|
||||
errCh := make(chan error, 8)
|
||||
|
||||
// Stop idempotente via sync.Once
|
||||
var once sync.Once
|
||||
stopFn := func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
})
|
||||
}
|
||||
|
||||
// Estado de backoff compartido con el wrapper
|
||||
backoffMs := initialBackoff
|
||||
lastSyncOK := time.Now()
|
||||
|
||||
// Configurar el Syncer: usar DefaultSyncer base (existente o nuevo)
|
||||
var baseSyncer *mautrix.DefaultSyncer
|
||||
if ds, ok := cfg.Client.Syncer.(*mautrix.DefaultSyncer); ok {
|
||||
baseSyncer = ds
|
||||
} else {
|
||||
baseSyncer = mautrix.NewDefaultSyncer()
|
||||
}
|
||||
|
||||
// Crear wrapper que intercepta OnFailedSync
|
||||
wrapper := &matrixSyncerWrapper{
|
||||
DefaultSyncer: baseSyncer,
|
||||
errCh: errCh,
|
||||
innerCtx: innerCtx,
|
||||
backoffMs: &backoffMs,
|
||||
initialMS: initialBackoff,
|
||||
maxMS: maxBackoff,
|
||||
lastSyncOK: &lastSyncOK,
|
||||
}
|
||||
cfg.Client.Syncer = wrapper
|
||||
|
||||
// Helper: emitir evento de forma no-bloqueante respetando ctx
|
||||
emit := func(ev MatrixSyncEvent) {
|
||||
select {
|
||||
case evtCh <- ev:
|
||||
case <-innerCtx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: extraer body de texto de Content.VeryRaw
|
||||
extractBody := func(evt *event.Event) string {
|
||||
raw := evt.Content.VeryRaw
|
||||
if raw == nil {
|
||||
return ""
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return ""
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
return b
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Registrar event handlers sobre el DefaultSyncer base
|
||||
|
||||
// m.room.message — mensajes de texto, imagen, archivo
|
||||
baseSyncer.OnEventType(event.EventMessage, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "message",
|
||||
RoomID: evt.RoomID.String(),
|
||||
EventID: evt.ID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Body: extractBody(evt),
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.room.encrypted — mensajes cifrados (crypto helper los descifra si esta init)
|
||||
baseSyncer.OnEventType(event.EventEncrypted, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "encrypted",
|
||||
RoomID: evt.RoomID.String(),
|
||||
EventID: evt.ID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.room.redaction — redacciones de mensajes
|
||||
baseSyncer.OnEventType(event.EventRedaction, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "redaction",
|
||||
RoomID: evt.RoomID.String(),
|
||||
EventID: evt.ID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.reaction — reacciones emoji
|
||||
baseSyncer.OnEventType(event.EventReaction, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "reaction",
|
||||
RoomID: evt.RoomID.String(),
|
||||
EventID: evt.ID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.room.member — cambios de pertenencia a sala
|
||||
baseSyncer.OnEventType(event.StateMember, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "membership",
|
||||
RoomID: evt.RoomID.String(),
|
||||
EventID: evt.ID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.typing — efimero: quien esta escribiendo en una sala
|
||||
baseSyncer.OnEventType(event.EphemeralEventTyping, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "typing",
|
||||
RoomID: evt.RoomID.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.receipt — read receipts
|
||||
baseSyncer.OnEventType(event.EphemeralEventReceipt, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "read_receipt",
|
||||
RoomID: evt.RoomID.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// m.presence — presencia de usuarios
|
||||
baseSyncer.OnEventType(event.EphemeralEventPresence, func(_ context.Context, evt *event.Event) {
|
||||
emit(MatrixSyncEvent{
|
||||
Type: "presence",
|
||||
Sender: evt.Sender.String(),
|
||||
Ts: evt.Timestamp,
|
||||
Raw: evt,
|
||||
})
|
||||
})
|
||||
|
||||
// Goroutine principal
|
||||
// SyncWithContext ya es un loop bloqueante que incluye retry via OnFailedSync.
|
||||
// Esta goroutine solo reinicia si SyncWithContext retorna con error inesperado.
|
||||
go func() {
|
||||
defer func() {
|
||||
cancel()
|
||||
close(evtCh)
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-innerCtx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
err := cfg.Client.SyncWithContext(innerCtx)
|
||||
|
||||
// ctx cancelado = salida limpia
|
||||
if innerCtx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// SyncWithContext retorna nil si otro Sync() lo cancelo
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Cualquier otro error: pequeno delay antes de reiniciar
|
||||
select {
|
||||
case <-innerCtx.Done():
|
||||
return
|
||||
case <-time.After(time.Duration(initialBackoff) * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return &MatrixSyncServiceHandle{
|
||||
Events: evtCh,
|
||||
Errors: errCh,
|
||||
Stop: stopFn,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isFatalMatrixError devuelve true si el error indica que no tiene sentido
|
||||
// reintentar (token invalido, forbidden).
|
||||
func isFatalMatrixError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := err.Error()
|
||||
return strings.Contains(msg, "M_UNKNOWN_TOKEN") ||
|
||||
strings.Contains(msg, "M_FORBIDDEN") ||
|
||||
strings.Contains(msg, "401")
|
||||
}
|
||||
+281
-21
@@ -2,12 +2,20 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/infra"
|
||||
"fn-registry/projects/element_agents/apps/matrix_client_pc/internal/infra"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"maunium.net/go/mautrix"
|
||||
"maunium.net/go/mautrix/event"
|
||||
"maunium.net/go/mautrix/id"
|
||||
)
|
||||
|
||||
// Constants are operator-configurable later via settings UI. Hardcoded for issue 0147 MVP.
|
||||
@@ -30,6 +38,10 @@ type MatrixService struct {
|
||||
ctx context.Context
|
||||
mu sync.Mutex
|
||||
store *infra.KeyringTokenStore
|
||||
client *mautrix.Client
|
||||
sync *infra.MatrixSyncServiceHandle
|
||||
crypto *infra.MatrixCryptoInitResult
|
||||
userID string
|
||||
}
|
||||
|
||||
func NewMatrixService() *MatrixService {
|
||||
@@ -51,6 +63,28 @@ type SessionView struct {
|
||||
ExpiresAt string `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// MatrixEvent is the exportable, JSON-friendly shape of a Matrix event for the frontend.
|
||||
type MatrixEvent struct {
|
||||
EventID string `json:"event_id"`
|
||||
RoomID string `json:"room_id"`
|
||||
Sender string `json:"sender"`
|
||||
Type string `json:"type"`
|
||||
Ts int64 `json:"ts"`
|
||||
Body string `json:"body,omitempty"`
|
||||
EncryptedRaw bool `json:"encrypted_raw"`
|
||||
}
|
||||
|
||||
// SyncEventView is what we emit through Wails events. Mirrors MatrixSyncEvent but without
|
||||
// the raw event pointer that wouldn't survive JSON serialization.
|
||||
type SyncEventView struct {
|
||||
Type string `json:"type"`
|
||||
RoomID string `json:"room_id"`
|
||||
EventID string `json:"event_id"`
|
||||
Sender string `json:"sender"`
|
||||
Ts int64 `json:"ts"`
|
||||
Body string `json:"body,omitempty"`
|
||||
}
|
||||
|
||||
// Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
|
||||
// Returns the user_id of the authenticated session.
|
||||
func (s *MatrixService) Login() (string, error) {
|
||||
@@ -70,29 +104,20 @@ func (s *MatrixService) Login() (string, error) {
|
||||
return "", fmt.Errorf("oidc: %w", err)
|
||||
}
|
||||
|
||||
// Initialize Matrix client to discover user_id + device_id via whoami.
|
||||
tmpStore := tempStoreDir()
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
HomeserverURL: homeserverURL,
|
||||
// UserID is unknown until whoami. mautrix-go requires it pre-set, but we'll
|
||||
// use Whoami via the Wails service directly. As shortcut: parse id_token if present.
|
||||
// For v0.1.0 use a placeholder + Whoami after; mautrix accepts empty UserID, then
|
||||
// updates after whoami call.
|
||||
UserID: "",
|
||||
AccessToken: res.AccessToken,
|
||||
StoreDir: tmpStore,
|
||||
EnableCrypto: false,
|
||||
}
|
||||
|
||||
// Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
|
||||
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("whoami: %w", err)
|
||||
}
|
||||
clientCfg.UserID = userID
|
||||
clientCfg.DeviceID = deviceID
|
||||
clientCfg.StoreDir = userStoreDir(userID)
|
||||
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
HomeserverURL: homeserverURL,
|
||||
UserID: userID,
|
||||
DeviceID: deviceID,
|
||||
AccessToken: res.AccessToken,
|
||||
StoreDir: userStoreDir(userID),
|
||||
EnableCrypto: false, // crypto goes through MatrixCryptoInit in Start()
|
||||
}
|
||||
if _, err := infra.MatrixClientInit(clientCfg); err != nil {
|
||||
return "", fmt.Errorf("matrix init: %w", err)
|
||||
}
|
||||
@@ -115,10 +140,9 @@ func (s *MatrixService) Login() (string, error) {
|
||||
return userID, nil
|
||||
}
|
||||
|
||||
// GetSession returns the persisted session for the given user_id (or last-known if empty).
|
||||
// GetSession returns the persisted session for the given user_id.
|
||||
func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
|
||||
if userID == "" {
|
||||
// v0.1.0: no multi-account index. Frontend must pass the user_id once known.
|
||||
return nil, errors.New("user_id required (v0.1.0 multi-account index TODO)")
|
||||
}
|
||||
tok, err := s.store.Load(userID)
|
||||
@@ -140,12 +164,248 @@ func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
|
||||
return view, nil
|
||||
}
|
||||
|
||||
// Logout deletes the persisted token for the given user_id.
|
||||
// Logout deletes the persisted token + stops sync.
|
||||
func (s *MatrixService) Logout(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if userID == "" {
|
||||
return errors.New("user_id required")
|
||||
}
|
||||
if s.sync != nil {
|
||||
s.sync.Stop()
|
||||
s.sync = nil
|
||||
}
|
||||
s.client = nil
|
||||
s.crypto = nil
|
||||
s.userID = ""
|
||||
return s.store.Delete(userID)
|
||||
}
|
||||
|
||||
// Stop shuts down the sync loop without deleting credentials. Safe to call multiple times.
|
||||
func (s *MatrixService) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.sync != nil {
|
||||
s.sync.Stop()
|
||||
s.sync = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Start initializes the Matrix client + crypto + sync loop for the given user.
|
||||
// Must be called after Login() or after a successful GetSession() for a returning user.
|
||||
// Idempotent: safe to call multiple times for the same user.
|
||||
func (s *MatrixService) Start(userID string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if userID == "" {
|
||||
return errors.New("user_id required")
|
||||
}
|
||||
|
||||
// Idempotent for same user
|
||||
if s.sync != nil && s.userID == userID {
|
||||
return nil
|
||||
}
|
||||
// Different user or restart: stop previous
|
||||
if s.sync != nil {
|
||||
s.sync.Stop()
|
||||
s.sync = nil
|
||||
}
|
||||
|
||||
tok, err := s.store.Load(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring load: %w", err)
|
||||
}
|
||||
|
||||
storeDir := userStoreDir(userID)
|
||||
clientCfg := infra.MatrixClientInitConfig{
|
||||
HomeserverURL: tok.HomeserverURL,
|
||||
UserID: tok.UserID,
|
||||
DeviceID: tok.DeviceID,
|
||||
AccessToken: tok.AccessToken,
|
||||
StoreDir: storeDir,
|
||||
EnableCrypto: false,
|
||||
}
|
||||
clientRes, err := infra.MatrixClientInit(clientCfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix init: %w", err)
|
||||
}
|
||||
|
||||
// Pickle key: load from keyring (hex), or generate fresh and persist.
|
||||
pickleKey, err := s.loadOrCreatePickleKey(tok)
|
||||
if err != nil {
|
||||
return fmt.Errorf("pickle key: %w", err)
|
||||
}
|
||||
|
||||
cryptoStorePath := filepath.Join(storeDir, "crypto.db")
|
||||
|
||||
// Wrap MatrixCryptoInit in 60s timeout — hang here is the canonical MAS-UIA-rejection signal.
|
||||
cryptoCtx, cancel := context.WithTimeout(s.ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cryptoRes, err := infra.MatrixCryptoInit(cryptoCtx, infra.MatrixCryptoInitConfig{
|
||||
Client: clientRes.Client,
|
||||
StorePath: cryptoStorePath,
|
||||
PickleKey: pickleKey,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix crypto init (hang here = MAS rejected UIA, see memory feedback_agents_e2ee_unblock_pattern): %w", err)
|
||||
}
|
||||
|
||||
syncRes, err := infra.MatrixSyncService(s.ctx, infra.MatrixSyncServiceConfig{
|
||||
Client: clientRes.Client,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("matrix sync: %w", err)
|
||||
}
|
||||
|
||||
s.client = clientRes.Client
|
||||
s.crypto = cryptoRes
|
||||
s.sync = syncRes
|
||||
s.userID = userID
|
||||
|
||||
// Fan events out via Wails runtime.
|
||||
go s.fanout()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *MatrixService) fanout() {
|
||||
if s.ctx == nil || s.sync == nil {
|
||||
return
|
||||
}
|
||||
events := s.sync.Events
|
||||
errs := s.sync.Errors
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
view := SyncEventView{
|
||||
Type: ev.Type,
|
||||
RoomID: ev.RoomID,
|
||||
EventID: ev.EventID,
|
||||
Sender: ev.Sender,
|
||||
Ts: ev.Ts,
|
||||
Body: ev.Body,
|
||||
}
|
||||
runtime.EventsEmit(s.ctx, "matrix:event", view)
|
||||
case e, ok := <-errs:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e != nil {
|
||||
runtime.EventsEmit(s.ctx, "matrix:error", e.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListRooms returns the joined rooms with summary metadata.
|
||||
func (s *MatrixService) ListRooms() ([]infra.RoomSummary, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil, errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
return infra.MatrixRoomList(s.ctx, infra.MatrixRoomListConfig{Client: client})
|
||||
}
|
||||
|
||||
// LoadTimeline fetches the last N messages of a room (most recent first).
|
||||
func (s *MatrixService) LoadTimeline(roomID string, limit int) ([]MatrixEvent, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return nil, errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
msgs, err := client.Messages(s.ctx, id.RoomID(roomID), "", "", mautrix.DirectionBackward, nil, limit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("messages: %w", err)
|
||||
}
|
||||
out := make([]MatrixEvent, 0, len(msgs.Chunk))
|
||||
for _, evt := range msgs.Chunk {
|
||||
out = append(out, eventToView(evt))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func eventToView(evt *event.Event) MatrixEvent {
|
||||
view := MatrixEvent{
|
||||
EventID: evt.ID.String(),
|
||||
RoomID: evt.RoomID.String(),
|
||||
Sender: evt.Sender.String(),
|
||||
Type: evt.Type.String(),
|
||||
Ts: evt.Timestamp,
|
||||
}
|
||||
if evt.Type == event.EventEncrypted {
|
||||
view.EncryptedRaw = true
|
||||
return view
|
||||
}
|
||||
// Try to parse and extract body for messages.
|
||||
if evt.Type == event.EventMessage {
|
||||
_ = evt.Content.ParseRaw(evt.Type)
|
||||
if mc, ok := evt.Content.Parsed.(*event.MessageEventContent); ok && mc != nil {
|
||||
view.Body = mc.Body
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
// SendText sends a plain text message to the given room.
|
||||
func (s *MatrixService) SendText(roomID, body string) (string, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return "", errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
evID, err := infra.MatrixSendText(s.ctx, client, id.RoomID(roomID), body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(evID), nil
|
||||
}
|
||||
|
||||
// SendMarkdown sends a markdown-formatted message (rendered + sanitized HTML).
|
||||
func (s *MatrixService) SendMarkdown(roomID, md string) (string, error) {
|
||||
s.mu.Lock()
|
||||
client := s.client
|
||||
s.mu.Unlock()
|
||||
if client == nil {
|
||||
return "", errors.New("matrix service not started — call Start() first")
|
||||
}
|
||||
evID, err := infra.MatrixSendMarkdown(s.ctx, client, id.RoomID(roomID), md)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(evID), nil
|
||||
}
|
||||
|
||||
// loadOrCreatePickleKey returns the 32-byte pickle key for the user.
|
||||
// If absent in keyring, generates fresh random bytes, hex-encodes them, persists, and returns.
|
||||
func (s *MatrixService) loadOrCreatePickleKey(tok *infra.Token) ([]byte, error) {
|
||||
if tok.PickleKeyHex != "" {
|
||||
key, err := hex.DecodeString(tok.PickleKeyHex)
|
||||
if err == nil && len(key) == 32 {
|
||||
return key, nil
|
||||
}
|
||||
// Malformed key in keyring — fall through and regenerate.
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return nil, fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
tok.PickleKeyHex = hex.EncodeToString(buf)
|
||||
if err := s.store.Save(tok.UserID, *tok); err != nil {
|
||||
return nil, fmt.Errorf("save pickle key: %w", err)
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package main
|
||||
|
||||
// sqlite_driver.go registers the SQLite driver required by the mautrix crypto
|
||||
// store. mautrix-go's cryptohelper uses database/sql + the "sqlite3" driver
|
||||
// name. go-sqlite3 self-registers via init() with sync.Once internally, but we
|
||||
// keep the blank import here to make the dependency explicit.
|
||||
//
|
||||
// Per the project memory (feedback_agents_e2ee_unblock_pattern.md), if the
|
||||
// driver is somehow registered twice the panic shows up as
|
||||
// `sql: Register called twice for driver sqlite3`. The guard pattern lives in
|
||||
// go-sqlite3 itself, so a blank import is sufficient.
|
||||
|
||||
import (
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
@@ -2,6 +2,7 @@
|
||||
"$schema": "https://wails.io/schemas/config.v2.json",
|
||||
"name": "matrix_client_pc",
|
||||
"outputfilename": "matrix_client_pc",
|
||||
"build:tags": "goolm",
|
||||
"frontend:install": "pnpm install",
|
||||
"frontend:build": "pnpm build",
|
||||
"frontend:dev:watcher": "pnpm dev",
|
||||
|
||||
Reference in New Issue
Block a user