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:
Egutierrez
2026-05-25 01:03:31 +02:00
parent f28c2b121e
commit 36a485ea26
21 changed files with 2529 additions and 93 deletions
+184 -50
View File
@@ -1,27 +1,39 @@
import { useEffect, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
AppShell, AppShell,
Avatar,
Badge, Badge,
Box, Box,
Burger,
Button, Button,
Code, Center,
Group, Group,
Paper, Loader,
Stack, Stack,
Text, Text,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { IconLogout, IconUserCircle } from "@tabler/icons-react"; import { useDisclosure } from "@mantine/hooks";
import { GetSession, Logout } from "../wailsjs/go/main/MatrixService"; 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 { const NAVBAR_WIDTH = 300;
user_id: string;
device_id: string;
homeserver_url: string;
has_token: boolean;
expires_at?: string;
}
export default function HomeScreen({ export default function HomeScreen({
userID, userID,
@@ -30,12 +42,63 @@ export default function HomeScreen({
userID: string; userID: string;
onLogout: () => void; 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(() => { 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]); }, [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() { async function handleLogout() {
try { try {
await Logout(userID); await Logout(userID);
@@ -44,22 +107,43 @@ export default function HomeScreen({
} }
} }
const initials = userID async function handleSendText(body: string) {
.replace("@", "") if (!activeRoomID) return;
.split(":")[0] await SendText(activeRoomID, body);
.slice(0, 2) }
.toUpperCase();
async function handleSendMarkdown(md: string) {
if (!activeRoomID) return;
await SendMarkdown(activeRoomID, md);
}
return ( 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> <AppShell.Header>
<Group h="100%" px="md" justify="space-between"> <Group h="100%" px="md" justify="space-between" wrap="nowrap">
<Group gap="xs"> <Group gap="xs" wrap="nowrap">
<Burger
opened={navOpen}
onClick={navHandlers.toggle}
size="sm"
aria-label="Toggle rooms"
/>
<IconUserCircle size={22} /> <IconUserCircle size={22} />
<Text fw={600}>matrix_client_pc</Text> <Text fw={600}>matrix_client_pc</Text>
<Badge size="sm" variant="light" color="violet"> <Badge size="sm" variant="light" color="violet">
v0.1.0 v0.2.0
</Badge> </Badge>
<Text size="xs" c="dimmed" visibleFrom="sm">
{userID}
</Text>
</Group> </Group>
<Button <Button
variant="subtle" variant="subtle"
@@ -71,41 +155,91 @@ export default function HomeScreen({
</Button> </Button>
</Group> </Group>
</AppShell.Header> </AppShell.Header>
<AppShell.Main>
<Box maw={720} mx="auto" mt="xl"> <AppShell.Navbar>
<Paper p="xl" radius="lg" withBorder> <RoomList
<Group gap="lg" align="flex-start"> rooms={rooms}
<Avatar size={64} color="violet" radius="xl"> activeRoomID={activeRoomID}
{initials} onSelect={setActiveRoomID}
</Avatar> />
<Stack gap={6} flex={1}> </AppShell.Navbar>
<Title order={3}>{userID}</Title>
{session ? ( <AppShell.Main
<Stack gap={4}> 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"> <Text size="sm" c="dimmed">
Device: <Code>{session.device_id || "-"}</Code> Starting sync and crypto (up to 60s on first run)...
</Text> </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"> <Text size="sm" c="dimmed">
Homeserver: <Code>{session.homeserver_url}</Code> {startError}
</Text> </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"> <Text size="xs" c="dimmed">
Token expira: {session.expires_at} {activeRoom.member_count} member{activeRoom.member_count === 1 ? "" : "s"}
</Text> </Text>
)}
</Stack>
) : (
<Text c="dimmed">Cargando sesion...</Text>
)}
</Stack>
</Group> </Group>
</Paper> {activeRoom.topic && (
<Paper p="md" radius="md" withBorder mt="lg"> <Text size="xs" c="dimmed" mt={4} lineClamp={1}>
<Text size="sm" c="dimmed"> {activeRoom.topic}
Login OK. Sync + rooms + timeline llegan en issue 0148.
</Text> </Text>
</Paper> )}
</Box> </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.Main>
</AppShell> </AppShell>
); );
+88
View File
@@ -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>
);
}
+80
View File
@@ -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>
);
}
+88
View File
@@ -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>
);
}
+64
View File
@@ -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>
);
}
+54
View File
@@ -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 };
}
+66
View File
@@ -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 };
}
+19
View File
@@ -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;
}
+20 -2
View File
@@ -2,13 +2,24 @@ module fn-registry/projects/element_agents/apps/matrix_client_pc
go 1.25.0 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 ( 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/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/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // 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/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // 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/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // 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/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/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // 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/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/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect golang.org/x/text v0.37.0 // indirect
+40
View File
@@ -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 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= 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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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= 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-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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 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 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= 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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4= github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 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/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 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k= 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 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= 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.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 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= 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= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=
-4
View File
@@ -13,10 +13,6 @@ import (
"time" "time"
) )
func tempStoreDir() string {
return filepath.Join(os.TempDir(), "matrix_client_pc_login")
}
func userStoreDir(userID string) string { func userStoreDir(userID string) string {
cfg, err := os.UserConfigDir() cfg, err := os.UserConfigDir()
if err != nil { if err != nil {
+84
View File
@@ -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
}
+382
View File
@@ -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
}
+153
View File
@@ -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
}
+107
View File
@@ -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
}
+121
View File
@@ -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)
}
+300
View File
@@ -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
}
+366
View File
@@ -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
View File
@@ -2,12 +2,20 @@ package main
import ( import (
"context" "context"
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"path/filepath"
"sync" "sync"
"time" "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. // Constants are operator-configurable later via settings UI. Hardcoded for issue 0147 MVP.
@@ -30,6 +38,10 @@ type MatrixService struct {
ctx context.Context ctx context.Context
mu sync.Mutex mu sync.Mutex
store *infra.KeyringTokenStore store *infra.KeyringTokenStore
client *mautrix.Client
sync *infra.MatrixSyncServiceHandle
crypto *infra.MatrixCryptoInitResult
userID string
} }
func NewMatrixService() *MatrixService { func NewMatrixService() *MatrixService {
@@ -51,6 +63,28 @@ type SessionView struct {
ExpiresAt string `json:"expires_at,omitempty"` 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. // Login launches the OAuth2 PKCE flow against MAS. Blocks until completion or timeout.
// Returns the user_id of the authenticated session. // Returns the user_id of the authenticated session.
func (s *MatrixService) Login() (string, error) { func (s *MatrixService) Login() (string, error) {
@@ -70,29 +104,20 @@ func (s *MatrixService) Login() (string, error) {
return "", fmt.Errorf("oidc: %w", err) 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). // Pre-fetch user_id by hitting /whoami directly (mautrix requires UserID at NewClient).
userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken) userID, deviceID, err := whoami(s.ctx, homeserverURL, res.AccessToken)
if err != nil { if err != nil {
return "", fmt.Errorf("whoami: %w", err) 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 { if _, err := infra.MatrixClientInit(clientCfg); err != nil {
return "", fmt.Errorf("matrix init: %w", err) return "", fmt.Errorf("matrix init: %w", err)
} }
@@ -115,10 +140,9 @@ func (s *MatrixService) Login() (string, error) {
return userID, nil 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) { func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
if userID == "" { 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)") return nil, errors.New("user_id required (v0.1.0 multi-account index TODO)")
} }
tok, err := s.store.Load(userID) tok, err := s.store.Load(userID)
@@ -140,12 +164,248 @@ func (s *MatrixService) GetSession(userID string) (*SessionView, error) {
return view, nil 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 { func (s *MatrixService) Logout(userID string) error {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if userID == "" { if userID == "" {
return errors.New("user_id required") 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) 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
}
+15
View File
@@ -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"
)
+1
View File
@@ -2,6 +2,7 @@
"$schema": "https://wails.io/schemas/config.v2.json", "$schema": "https://wails.io/schemas/config.v2.json",
"name": "matrix_client_pc", "name": "matrix_client_pc",
"outputfilename": "matrix_client_pc", "outputfilename": "matrix_client_pc",
"build:tags": "goolm",
"frontend:install": "pnpm install", "frontend:install": "pnpm install",
"frontend:build": "pnpm build", "frontend:build": "pnpm build",
"frontend:dev:watcher": "pnpm dev", "frontend:dev:watcher": "pnpm dev",