mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-06-12 19:55:19 +02:00
calendar service
This commit is contained in:
parent
8602330bbc
commit
d363363b70
8 changed files with 310 additions and 11 deletions
|
|
@ -1,7 +1,11 @@
|
|||
import { BrowserWindow } from "electron";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { WorkDir } from "@x/core/dist/config/config.js";
|
||||
|
||||
export const DEEP_LINK_SCHEME = "rowboat";
|
||||
const URL_PREFIX = `${DEEP_LINK_SCHEME}://`;
|
||||
const ACTION_HOST = "action";
|
||||
|
||||
let pendingUrl: string | null = null;
|
||||
let mainWindowRef: BrowserWindow | null = null;
|
||||
|
|
@ -23,6 +27,18 @@ export function extractDeepLinkFromArgv(argv: readonly string[]): string | null
|
|||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch any rowboat:// URL — chooses navigation vs action automatically.
|
||||
* Use this from notification click handlers and other URL entry points.
|
||||
*/
|
||||
export function dispatchUrl(url: string): void {
|
||||
if (parseAction(url)) {
|
||||
void dispatchAction(url);
|
||||
} else {
|
||||
dispatchDeepLink(url);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchDeepLink(url: string): void {
|
||||
if (!url.startsWith(URL_PREFIX)) return;
|
||||
|
||||
|
|
@ -30,13 +46,72 @@ export function dispatchDeepLink(url: string): void {
|
|||
|
||||
const win = mainWindowRef;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
focusWindow(win);
|
||||
|
||||
if (win.webContents.isLoading()) return;
|
||||
|
||||
win.webContents.send("app:openUrl", { url });
|
||||
pendingUrl = null;
|
||||
}
|
||||
|
||||
interface TakeMeetingNotesAction {
|
||||
type: "take-meeting-notes";
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
type ParsedAction = TakeMeetingNotesAction;
|
||||
|
||||
function parseAction(url: string): ParsedAction | null {
|
||||
if (!url.startsWith(URL_PREFIX)) return null;
|
||||
const rest = url.slice(URL_PREFIX.length);
|
||||
const queryIdx = rest.indexOf("?");
|
||||
const host = (queryIdx >= 0 ? rest.slice(0, queryIdx) : rest).replace(/\/$/, "");
|
||||
if (host !== ACTION_HOST) return null;
|
||||
const params = new URLSearchParams(queryIdx >= 0 ? rest.slice(queryIdx + 1) : "");
|
||||
const type = params.get("type");
|
||||
if (type === "take-meeting-notes") {
|
||||
const eventId = params.get("eventId");
|
||||
return eventId ? { type, eventId } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function dispatchAction(url: string): Promise<void> {
|
||||
const parsed = parseAction(url);
|
||||
if (!parsed) return;
|
||||
|
||||
if (parsed.type === "take-meeting-notes") {
|
||||
await handleTakeMeetingNotes(parsed.eventId);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTakeMeetingNotes(eventId: string): Promise<void> {
|
||||
const win = mainWindowRef;
|
||||
if (!win || win.isDestroyed()) return;
|
||||
focusWindow(win);
|
||||
|
||||
const filePath = path.join(WorkDir, "calendar_sync", `${eventId}.json`);
|
||||
let event: unknown;
|
||||
try {
|
||||
const raw = await fs.readFile(filePath, "utf-8");
|
||||
event = JSON.parse(raw);
|
||||
} catch (err) {
|
||||
console.error(`[deeplink] take-meeting-notes: failed to read ${filePath}`, err);
|
||||
return;
|
||||
}
|
||||
|
||||
if (win.webContents.isLoading()) {
|
||||
win.webContents.once("did-finish-load", () => {
|
||||
win.webContents.send("app:takeMeetingNotes", { event });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
win.webContents.send("app:takeMeetingNotes", { event });
|
||||
}
|
||||
|
||||
function focusWindow(win: BrowserWindow): void {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
|||
import { init as initInlineTasks } from "@x/core/dist/knowledge/inline_tasks.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { init as initAgentNotes } from "@x/core/dist/knowledge/agent_notes.js";
|
||||
import { init as initCalendarNotifications } from "@x/core/dist/knowledge/notify_calendar_meetings.js";
|
||||
import { init as initTrackScheduler } from "@x/core/dist/knowledge/track/scheduler.js";
|
||||
import { init as initTrackEventProcessor } from "@x/core/dist/knowledge/track/events.js";
|
||||
import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js";
|
||||
|
|
@ -337,6 +338,9 @@ app.whenReady().then(async () => {
|
|||
// start agent notes learning service
|
||||
initAgentNotes();
|
||||
|
||||
// start calendar meeting notification service (fires 1-minute warnings)
|
||||
initCalendarNotifications();
|
||||
|
||||
// start chrome extension sync server
|
||||
initChromeSync();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BrowserWindow, Notification, shell } from "electron";
|
||||
import type { INotificationService, NotifyInput } from "@x/core/dist/application/notification/service.js";
|
||||
import { dispatchDeepLink } from "../deeplink.js";
|
||||
import { dispatchUrl } from "../deeplink.js";
|
||||
|
||||
const HTTP_URL = /^https?:\/\//i;
|
||||
const ROWBOAT_URL = /^rowboat:\/\//i;
|
||||
|
|
@ -15,18 +15,23 @@ export class ElectronNotificationService implements INotificationService {
|
|||
return Notification.isSupported();
|
||||
}
|
||||
|
||||
notify({ title = "Rowboat", message, link }: NotifyInput): void {
|
||||
notify({ title = "Rowboat", message, link, actionLabel }: NotifyInput): void {
|
||||
const notification = new Notification({
|
||||
title,
|
||||
body: message,
|
||||
// Action button is only meaningful when there's a link to drive it.
|
||||
// macOS shows the first action inline (Banner) or behind chevron (Alert).
|
||||
actions: link && actionLabel?.trim()
|
||||
? [{ type: "button", text: actionLabel.trim() }]
|
||||
: [],
|
||||
});
|
||||
|
||||
this.active.add(notification);
|
||||
const release = () => { this.active.delete(notification); };
|
||||
|
||||
notification.on("click", () => {
|
||||
const handleClick = () => {
|
||||
if (link && ROWBOAT_URL.test(link)) {
|
||||
dispatchDeepLink(link);
|
||||
dispatchUrl(link);
|
||||
} else if (link && HTTP_URL.test(link)) {
|
||||
shell.openExternal(link).catch((err) => {
|
||||
console.error("[notification] failed to open link:", err);
|
||||
|
|
@ -35,7 +40,13 @@ export class ElectronNotificationService implements INotificationService {
|
|||
this.focusMainWindow();
|
||||
}
|
||||
release();
|
||||
});
|
||||
};
|
||||
|
||||
// Both events route through the same handler. Body click on macOS is
|
||||
// unreliable when actions are defined, but we register both so either
|
||||
// one (whichever fires) drives the same behavior.
|
||||
notification.on("click", handleClick);
|
||||
notification.on("action", handleClick);
|
||||
notification.on("close", release);
|
||||
notification.on("failed", release);
|
||||
|
||||
|
|
|
|||
|
|
@ -3105,6 +3105,35 @@ function App() {
|
|||
return window.ipc.on('app:openUrl', ({ url }) => handle(url))
|
||||
}, [])
|
||||
|
||||
// Triggered by main when the user clicks a calendar-meeting notification.
|
||||
// Reuses the same flow as the in-app "Join meeting & take notes" button.
|
||||
useEffect(() => {
|
||||
return window.ipc.on('app:takeMeetingNotes', ({ event }) => {
|
||||
const e = event as {
|
||||
summary?: string
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
end?: { dateTime?: string; date?: string; timeZone?: string }
|
||||
location?: string
|
||||
htmlLink?: string
|
||||
hangoutLink?: string
|
||||
conferenceData?: { entryPoints?: Array<{ entryPointType?: string; uri?: string }> }
|
||||
}
|
||||
if (!e || typeof e !== 'object') return
|
||||
const conferenceLink = e.hangoutLink
|
||||
|| e.conferenceData?.entryPoints?.find(p => p.entryPointType === 'video')?.uri
|
||||
window.__pendingCalendarEvent = {
|
||||
summary: e.summary,
|
||||
start: e.start,
|
||||
end: e.end,
|
||||
location: e.location,
|
||||
htmlLink: e.htmlLink,
|
||||
conferenceLink,
|
||||
source: 'calendar-sync',
|
||||
}
|
||||
window.dispatchEvent(new Event('calendar-block:join-meeting'))
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleBaseConfigChange = useCallback((path: string, config: BaseConfig) => {
|
||||
setBaseConfigByPath((prev) => ({ ...prev, [path]: config }))
|
||||
}, [])
|
||||
|
|
|
|||
|
|
@ -1524,6 +1524,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
|
||||
message: "link must be an http(s):// or rowboat:// URL",
|
||||
}).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."),
|
||||
actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body. Note: macOS may render the button inline (Banner) or behind a chevron (Alert) depending on the user's notification style for Rowboat."),
|
||||
}),
|
||||
isAvailable: async () => {
|
||||
try {
|
||||
|
|
@ -1532,13 +1533,13 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
return false;
|
||||
}
|
||||
},
|
||||
execute: async ({ title, message, link }: { title?: string; message: string; link?: string }) => {
|
||||
execute: async ({ title, message, link, actionLabel }: { title?: string; message: string; link?: string; actionLabel?: string }) => {
|
||||
try {
|
||||
const service = container.resolve<INotificationService>('notificationService');
|
||||
if (!service.isSupported()) {
|
||||
return { success: false, error: 'Notifications are not supported on this system' };
|
||||
}
|
||||
service.notify({ title, message, link });
|
||||
service.notify({ title, message, link, actionLabel });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ export interface NotifyInput {
|
|||
title?: string;
|
||||
message: string;
|
||||
link?: string;
|
||||
actionLabel?: string;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
|
|
|
|||
171
apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts
Normal file
171
apps/x/packages/core/src/knowledge/notify_calendar_meetings.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import type { Dirent } from "node:fs";
|
||||
import { WorkDir } from "../config/config.js";
|
||||
import container from "../di/container.js";
|
||||
import type { INotificationService } from "../application/notification/service.js";
|
||||
|
||||
const TICK_INTERVAL_MS = 60_000;
|
||||
// Notify when an event is between 30s in the past (started just now) and
|
||||
// 90s in the future (about to start). The window is wider than 60s so we
|
||||
// don't miss an event if the tick lands slightly off the start time.
|
||||
const NOTIFY_LEAD_MS = 90_000;
|
||||
const NOTIFY_GRACE_MS = 30_000;
|
||||
// Drop state entries older than 24h so the file doesn't grow forever.
|
||||
const STATE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
|
||||
const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json");
|
||||
|
||||
interface NotificationState {
|
||||
notifiedEventIds: Record<string, { notifiedAt: string; startTime: string }>;
|
||||
}
|
||||
|
||||
interface CalendarEvent {
|
||||
id?: string;
|
||||
summary?: string;
|
||||
status?: string;
|
||||
start?: { dateTime?: string; date?: string; timeZone?: string };
|
||||
end?: { dateTime?: string; date?: string };
|
||||
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>;
|
||||
hangoutLink?: string;
|
||||
conferenceData?: {
|
||||
entryPoints?: Array<{ entryPointType?: string; uri?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
async function loadState(): Promise<NotificationState> {
|
||||
try {
|
||||
const raw = await fs.readFile(STATE_FILE, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) {
|
||||
return parsed as NotificationState;
|
||||
}
|
||||
} catch {
|
||||
// No state file yet, or corrupt — start fresh.
|
||||
}
|
||||
return { notifiedEventIds: {} };
|
||||
}
|
||||
|
||||
async function saveState(state: NotificationState): Promise<void> {
|
||||
await fs.writeFile(STATE_FILE, JSON.stringify(state, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
function gcState(state: NotificationState): NotificationState {
|
||||
const cutoff = Date.now() - STATE_TTL_MS;
|
||||
const fresh: NotificationState["notifiedEventIds"] = {};
|
||||
for (const [id, entry] of Object.entries(state.notifiedEventIds)) {
|
||||
const ts = Date.parse(entry.notifiedAt);
|
||||
if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry;
|
||||
}
|
||||
return { notifiedEventIds: fresh };
|
||||
}
|
||||
|
||||
function isAllDay(event: CalendarEvent): boolean {
|
||||
// Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`.
|
||||
return !event.start?.dateTime;
|
||||
}
|
||||
|
||||
function isDeclinedBySelf(event: CalendarEvent): boolean {
|
||||
if (!event.attendees) return false;
|
||||
const self = event.attendees.find((a) => a.self);
|
||||
return self?.responseStatus === "declined";
|
||||
}
|
||||
|
||||
async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> {
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true });
|
||||
} catch {
|
||||
return { state, dirty: false };
|
||||
}
|
||||
|
||||
let service: INotificationService;
|
||||
try {
|
||||
service = container.resolve<INotificationService>("notificationService");
|
||||
} catch {
|
||||
// Notification service not registered yet (very early startup) — skip this tick.
|
||||
return { state, dirty: false };
|
||||
}
|
||||
if (!service.isSupported()) return { state, dirty: false };
|
||||
|
||||
const now = Date.now();
|
||||
let dirty = false;
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
||||
if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue;
|
||||
|
||||
const eventId = entry.name.replace(/\.json$/, "");
|
||||
if (state.notifiedEventIds[eventId]) continue;
|
||||
|
||||
const filePath = path.join(CALENDAR_SYNC_DIR, entry.name);
|
||||
let event: CalendarEvent;
|
||||
try {
|
||||
event = JSON.parse(await fs.readFile(filePath, "utf-8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.status === "cancelled") continue;
|
||||
if (isAllDay(event)) continue;
|
||||
if (isDeclinedBySelf(event)) continue;
|
||||
|
||||
const startStr = event.start?.dateTime;
|
||||
if (!startStr) continue;
|
||||
const startMs = Date.parse(startStr);
|
||||
if (!Number.isFinite(startMs)) continue;
|
||||
|
||||
const msUntilStart = startMs - now;
|
||||
if (msUntilStart > NOTIFY_LEAD_MS) continue;
|
||||
if (msUntilStart < -NOTIFY_GRACE_MS) continue;
|
||||
|
||||
const summary = event.summary?.trim() || "Untitled meeting";
|
||||
const link = `rowboat://action?type=take-meeting-notes&eventId=${encodeURIComponent(eventId)}`;
|
||||
|
||||
try {
|
||||
service.notify({
|
||||
title: "Upcoming meeting",
|
||||
message: `${summary} starts in 1 minute. Click to take notes.`,
|
||||
link,
|
||||
actionLabel: "Take Notes",
|
||||
});
|
||||
console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`);
|
||||
} catch (err) {
|
||||
console.error(`[CalendarNotify] notify failed for ${eventId}:`, err);
|
||||
continue;
|
||||
}
|
||||
|
||||
state.notifiedEventIds[eventId] = {
|
||||
notifiedAt: new Date().toISOString(),
|
||||
startTime: startStr,
|
||||
};
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
return { state, dirty };
|
||||
}
|
||||
|
||||
export async function init(): Promise<void> {
|
||||
console.log("[CalendarNotify] starting calendar notification service");
|
||||
console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`);
|
||||
|
||||
let state = gcState(await loadState());
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
const result = await tick(state);
|
||||
state = result.state;
|
||||
if (result.dirty) {
|
||||
try {
|
||||
await saveState(state);
|
||||
} catch (err) {
|
||||
console.error("[CalendarNotify] failed to save state:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[CalendarNotify] tick failed:", err);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS));
|
||||
}
|
||||
}
|
||||
|
|
@ -298,6 +298,13 @@ const ipcSchemas = {
|
|||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'app:takeMeetingNotes': {
|
||||
req: z.object({
|
||||
// Pass the raw calendar event JSON through; renderer adapts to its existing flow.
|
||||
event: z.unknown(),
|
||||
}),
|
||||
res: z.null(),
|
||||
},
|
||||
'app:consumePendingDeepLink': {
|
||||
req: z.null(),
|
||||
res: z.object({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue