add whatsapp and telegram support

This commit is contained in:
Arjun 2026-07-03 19:25:06 +05:30
parent 3ba94402d3
commit fc9a76e1cb
15 changed files with 2244 additions and 9 deletions

View file

@ -51,6 +51,8 @@ import type { CodeSession } from '@x/shared/dist/code-sessions.js';
import { invalidateCopilotInstructionsCache } from '@x/core/dist/application/assistant/instructions.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { ISlackConfigRepo } from '@x/core/dist/slack/repo.js';
import { IChannelsConfigRepo } from '@x/core/dist/channels/repo.js';
import { applyChannelsConfig, getChannelsStatus, logoutWhatsApp, subscribeChannelsStatus } from '@x/core/dist/channels/service.js';
import { runAgentSlack, getAgentSlackCliStatus, AgentSlackRunError } from '@x/core/dist/slack/agent-slack-exec.js';
import { knowledgeSourcesRepo } from '@x/core/dist/knowledge/sources/repo.js';
import { rankSlackHomeMessages } from '@x/core/dist/knowledge/sources/rank_slack_home.js';
@ -646,6 +648,20 @@ function emitSessionEvent(event: SessionBusEvent): void {
}
}
// Mobile channels: status changes (QR pairing, connect/disconnect) → renderer.
let channelsWatcher: (() => void) | null = null;
export function startChannelsWatcher(): void {
if (channelsWatcher) return;
channelsWatcher = subscribeChannelsStatus((status) => {
const windows = BrowserWindow.getAllWindows();
for (const win of windows) {
if (!win.isDestroyed() && win.webContents) {
win.webContents.send('channels:status', status);
}
}
});
}
let sessionsWatcher: (() => void) | null = null;
export function startSessionsWatcher(): void {
if (sessionsWatcher) {
@ -1208,6 +1224,22 @@ export function setupIpcHandlers() {
return { success: true };
},
// ── Mobile channels (WhatsApp / Telegram bridge) ─────────────
'channels:getConfig': async () => {
return container.resolve<IChannelsConfigRepo>('channelsConfigRepo').getConfig();
},
'channels:setConfig': async (_event, args) => {
await container.resolve<IChannelsConfigRepo>('channelsConfigRepo').setConfig(args);
await applyChannelsConfig(args);
return { success: true };
},
'channels:getStatus': async () => {
return getChannelsStatus();
},
'channels:whatsappLogout': async () => {
await logoutWhatsApp();
return { success: true };
},
'slack:getConfig': async () => {
const repo = container.resolve<ISlackConfigRepo>('slackConfigRepo');
const config = await repo.getConfig();

View file

@ -3,6 +3,7 @@ import path from "node:path";
import {
setupIpcHandlers,
startRunsWatcher, startSessionsWatcher,
startChannelsWatcher,
startCodeSessionStatusWatcher,
startServicesWatcher,
startLiveNoteAgentWatcher,
@ -25,6 +26,7 @@ import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.j
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 initChannels } from "@x/core/dist/channels/service.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 initMeetingPrep } from "@x/core/dist/knowledge/meeting_prep_scheduler.js";
@ -414,6 +416,13 @@ app.whenReady().then(async () => {
await container.resolve<ISessions>('sessions').initialize();
startSessionsWatcher();
// Mobile channels (WhatsApp/Telegram bridge): needs the session index, so
// start after initialize(). Failures must never block boot.
startChannelsWatcher();
initChannels().catch((error) => {
console.error('[Channels] Failed to start mobile channels:', error);
});
// start code-session status tracker (derives working/needs-you/idle + notifications)
startCodeSessionStatusWatcher();

View file

@ -2,7 +2,7 @@
import * as React from "react"
import { useState, useEffect, useCallback, useMemo } from "react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell } from "lucide-react"
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X, Wrench, Search, ChevronRight, Link2, Tags, Mail, BookOpen, User, Plug, HelpCircle, MessageCircle, Bug, Terminal, AlertTriangle, RefreshCw, PanelRight, Bell, Smartphone } from "lucide-react"
import {
Dialog,
@ -25,10 +25,11 @@ import { useTheme } from "@/contexts/theme-context"
import { toast } from "sonner"
import { AccountSettings } from "@/components/settings/account-settings"
import { ConnectedAccountsSettings } from "@/components/settings/connected-accounts-settings"
import { MobileChannelsSettings } from "@/components/settings/mobile-channels-settings"
import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
type ConfigTab = "account" | "connections" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
interface TabConfig {
id: ConfigTab
@ -51,6 +52,12 @@ const tabs: TabConfig[] = [
icon: Plug,
description: "Manage accounts and tools",
},
{
id: "mobile",
label: "Mobile",
icon: Smartphone,
description: "Chat with Rowboat from WhatsApp or Telegram",
},
{
id: "models",
label: "Models",
@ -2216,7 +2223,7 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
</div>
{/* Content */}
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
<div className={cn("flex-1 p-4 min-h-0", (activeTab === "models" || activeTab === "connections" || activeTab === "mobile" || activeTab === "account" || activeTab === "code-mode" || activeTab === "notifications") ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
{activeTab === "account" ? (
<AccountSettings dialogOpen={open} />
) : activeTab === "connections" ? (
@ -2231,6 +2238,8 @@ export function SettingsDialog({ children, defaultTab = "account", open: control
<ToolsLibrarySettings dialogOpen={open} rowboatConnected={rowboatConnected} />
</div>
</div>
) : activeTab === "mobile" ? (
<MobileChannelsSettings dialogOpen={open} />
) : activeTab === "models" ? (
rowboatConnected
? <RowboatModelSettings dialogOpen={open} />

View file

@ -0,0 +1,285 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import type { z } from "zod"
import { Loader2, MessageCircle, Send, Smartphone } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { toast } from "sonner"
import type { ChannelsConfig, ChannelsStatus } from "@x/shared/src/channels.js"
type Config = z.infer<typeof ChannelsConfig>
type Status = z.infer<typeof ChannelsStatus>
// Comma/space/newline separated ids ↔ list.
function parseIdList(draft: string): string[] {
return draft
.split(/[\s,]+/)
.map((s) => s.replace(/[^\d]/g, ""))
.filter(Boolean)
}
export function MobileChannelsSettings({ dialogOpen }: { dialogOpen: boolean }) {
const [config, setConfig] = useState<Config | null>(null)
const [status, setStatus] = useState<Status | null>(null)
const [tokenDraft, setTokenDraft] = useState("")
const [waAllowDraft, setWaAllowDraft] = useState("")
const [tgAllowDraft, setTgAllowDraft] = useState("")
const [saving, setSaving] = useState(false)
useEffect(() => {
if (!dialogOpen) return
let cancelled = false
void (async () => {
try {
const [cfg, st] = await Promise.all([
window.ipc.invoke("channels:getConfig", null),
window.ipc.invoke("channels:getStatus", null),
])
if (cancelled) return
setConfig(cfg)
setStatus(st)
setTokenDraft(cfg.telegram.botToken)
setWaAllowDraft(cfg.whatsapp.allowFrom.join(", "))
setTgAllowDraft(cfg.telegram.allowFrom.join(", "))
} catch {
if (!cancelled) toast.error("Failed to load mobile channel settings")
}
})()
const unsubscribe = window.ipc.on("channels:status", (st) => {
setStatus(st)
})
return () => {
cancelled = true
unsubscribe()
}
}, [dialogOpen])
const save = useCallback(async (next: Config) => {
setConfig(next)
setSaving(true)
try {
await window.ipc.invoke("channels:setConfig", next)
} catch (error) {
toast.error(error instanceof Error ? error.message : "Failed to save channel settings")
} finally {
setSaving(false)
}
}, [])
if (!config) {
return (
<div className="flex items-center justify-center py-8 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
</div>
)
}
const wa = status?.whatsapp
const tg = status?.telegram
return (
<div className="space-y-6">
<div className="flex items-start gap-2.5 rounded-md bg-muted/50 px-3 py-2.5">
<Smartphone className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<p className="text-xs text-muted-foreground">
Chat with Rowboat from your phone. Send <span className="font-mono">help</span> for
commands (<span className="font-mono">list</span>, <span className="font-mono">resume 2</span>,{" "}
<span className="font-mono">new</span>, <span className="font-mono">stop</span>) anything
else is a message to the current chat. Your computer must be on with Rowboat running.
</p>
</div>
{/* WhatsApp */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<MessageCircle className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">WhatsApp</span>
<span className="text-xs text-muted-foreground">
{wa?.state === "connected"
? `Linked as +${wa.self ?? "?"} — message yourself to use it`
: wa?.state === "qr"
? "Scan the QR below with your phone"
: wa?.state === "starting"
? "Connecting…"
: wa?.state === "error"
? wa.error ?? "Error"
: "Links your own WhatsApp as a companion device"}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{wa?.state === "connected" && (
<Button
variant="outline"
size="sm"
className="h-7 px-3 text-xs"
onClick={() => {
void window.ipc.invoke("channels:whatsappLogout", null).catch(() => {
toast.error("Failed to unlink WhatsApp")
})
}}
>
Unlink
</Button>
)}
<Switch
checked={config.whatsapp.enabled}
disabled={saving}
onCheckedChange={(enabled) =>
void save({ ...config, whatsapp: { ...config.whatsapp, enabled } })
}
/>
</div>
</div>
{config.whatsapp.enabled && wa?.state === "qr" && wa.qrDataUrl && (
<div className="flex items-center gap-4 rounded-md border p-3">
<img src={wa.qrDataUrl} alt="WhatsApp pairing QR" className="size-40 rounded" />
<div className="text-xs text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Link Rowboat to WhatsApp</p>
<p>1. Open WhatsApp on your phone</p>
<p>2. Settings Linked Devices Link a Device</p>
<p>3. Scan this code</p>
<p className="pt-1">
Then message <span className="font-medium">yourself</span> (your own contact) to talk
to Rowboat.
</p>
</div>
</div>
)}
{config.whatsapp.enabled && (
<div className="space-y-1.5">
<label className="text-xs font-medium">Additional allowed numbers</label>
<div className="flex gap-2">
<Input
value={waAllowDraft}
onChange={(e) => setWaAllowDraft(e.target.value)}
placeholder="e.g. 14155551234, 919876543210"
className="h-8 text-xs"
/>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={saving}
onClick={() =>
void save({
...config,
whatsapp: { ...config.whatsapp, allowFrom: parseIdList(waAllowDraft) },
})
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Your own number (self-chat) is always allowed. Digits only, with country code.
</p>
</div>
)}
</div>
<Separator />
{/* Telegram */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="flex size-8 items-center justify-center rounded-md bg-muted">
<Send className="size-4" />
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">Telegram</span>
<span className="text-xs text-muted-foreground">
{tg?.state === "polling"
? `Listening${tg.botUsername ? ` as @${tg.botUsername}` : ""}`
: tg?.state === "starting"
? "Connecting…"
: tg?.state === "error"
? tg.error ?? "Error"
: "Uses your own bot — create one with @BotFather"}
</span>
</div>
</div>
<Switch
checked={config.telegram.enabled}
disabled={saving}
onCheckedChange={(enabled) =>
void save({ ...config, telegram: { ...config.telegram, enabled } })
}
/>
</div>
{config.telegram.enabled && (
<>
<div className="space-y-1.5">
<label className="text-xs font-medium">Bot token</label>
<div className="flex gap-2">
<Input
type="password"
value={tokenDraft}
onChange={(e) => setTokenDraft(e.target.value)}
placeholder="123456789:AAF…"
className="h-8 text-xs font-mono"
/>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={saving}
onClick={() =>
void save({
...config,
telegram: { ...config.telegram, botToken: tokenDraft.trim() },
})
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Message @BotFather on Telegram /newbot paste the token here.
</p>
</div>
<div className="space-y-1.5">
<label className="text-xs font-medium">Allowed chat IDs</label>
<div className="flex gap-2">
<Input
value={tgAllowDraft}
onChange={(e) => setTgAllowDraft(e.target.value)}
placeholder="e.g. 123456789"
className="h-8 text-xs"
/>
<Button
variant="outline"
size="sm"
className="h-8 px-3 text-xs"
disabled={saving}
onClick={() =>
void save({
...config,
telegram: { ...config.telegram, allowFrom: parseIdList(tgAllowDraft) },
})
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
Message your bot once it replies with your chat ID to add here.
</p>
</div>
</>
)}
</div>
</div>
)
}