Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions lib/public/assets/icons/whatsapp.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
46 changes: 32 additions & 14 deletions lib/public/js/components/agents-tab/create-channel-modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const kChannelEnvKeys = {
telegram: "TELEGRAM_BOT_TOKEN",
discord: "DISCORD_BOT_TOKEN",
slack: "SLACK_BOT_TOKEN",
whatsapp: "WHATSAPP_OWNER_NUMBER",
};

const kChannelExtraEnvKeys = {
Expand Down Expand Up @@ -152,10 +153,13 @@ export const CreateChannelModal = ({
}
setName(providerLabel);
}, [provider, providerHasAccounts, nameEditedManually, isEditMode]);
const normalizedProvider = String(provider || "").trim();
const isSingleAccountProvider =
String(provider || "").trim() === "discord" ||
String(provider || "").trim() === "slack";
const needsAppToken = String(provider || "").trim() === "slack";
normalizedProvider === "discord" ||
normalizedProvider === "slack" ||
normalizedProvider === "whatsapp";
const needsAppToken = normalizedProvider === "slack";
const isWhatsApp = normalizedProvider === "whatsapp";

const accountId = useMemo(() => {
if (isEditMode) {
Expand Down Expand Up @@ -327,19 +331,33 @@ export const CreateChannelModal = ({

<label class="block space-y-1">
<span class="text-xs text-gray-400">
${needsAppToken ? "Bot Token" : "Token"}
${isWhatsApp ? "Owner Number" : needsAppToken ? "Bot Token" : "Token"}
</span>
<${SecretInput}
value=${token}
onInput=${(event) => setToken(event.target.value)}
placeholder=${token ? "" : "Paste bot token"}
loading=${loadingToken}
isSecret=${true}
inputClass="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
/>
${isWhatsApp
? html`
<input
type="text"
value=${token}
onInput=${(event) => setToken(event.target.value)}
placeholder="+15551234567"
class="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
/>
`
: html`
<${SecretInput}
value=${token}
onInput=${(event) => setToken(event.target.value)}
placeholder=${token ? "" : "Paste bot token"}
loading=${loadingToken}
isSecret=${true}
inputClass="w-full bg-black/30 border border-border rounded-lg px-3 py-2 text-sm font-mono text-gray-200 outline-none focus:border-gray-500"
/>
`}
<p class="text-xs text-gray-500">
Saved behind the scenes as
<code class="font-mono text-gray-400 ml-1">${envKey || "CHANNEL_TOKEN"}</code>.
${isWhatsApp
? "E.164 format phone number used for allowlist pairing."
: html`Saved behind the scenes as
<code class="font-mono text-gray-400 ml-1">${envKey || "CHANNEL_TOKEN"}</code>.`}
</p>
</label>

Expand Down
78 changes: 78 additions & 0 deletions lib/public/js/components/channel-login-modal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { h } from "https://esm.sh/preact";
import htm from "https://esm.sh/htm";
import { ActionButton } from "./action-button.js";
import { CloseIcon } from "./icons.js";
import { ModalShell } from "./modal-shell.js";
import { PageHeader } from "./page-header.js";

const html = htm.bind(h);

export const ChannelLoginModal = ({
visible = false,
loading = false,
title = "Link Channel",
output = "",
error = "",
onRun = async () => {},
onClose = () => {},
}) => {
if (!visible) return null;
const hasOutput = !!String(output || "").trim();
const hasError = !!String(error || "").trim();
const displayOutput = hasOutput
? String(output)
: hasError
? String(error)
: "No output yet. Generate QR to start login.";
return html`
<${ModalShell}
visible=${visible}
onClose=${onClose}
panelClassName="bg-modal border border-border rounded-xl p-6 max-w-2xl w-full space-y-4"
>
<${PageHeader}
title=${title}
actions=${html`
<button
type="button"
onclick=${onClose}
class="h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
aria-label="Close modal"
>
<${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
</button>
`}
/>
<div class="space-y-3">
<p class="text-xs text-gray-500">
Click "Generate QR" to run channel login and capture terminal output.
</p>
<textarea
readonly
wrap="off"
value=${displayOutput}
class="w-full h-[340px] max-h-[70vh] text-[11px] leading-[1.1] font-mono text-gray-300 bg-black/30 border border-border rounded-lg p-3 outline-none resize-y overflow-auto"
/>
</div>
<div class="flex justify-end gap-2 pt-1">
<${ActionButton}
onClick=${onClose}
disabled=${loading}
loading=${false}
tone="secondary"
size="sm"
idleLabel="Close"
/>
<${ActionButton}
onClick=${onRun}
disabled=${loading}
loading=${loading}
tone="primary"
size="sm"
idleLabel="Generate QR"
loadingLabel="Running..."
/>
</div>
</${ModalShell}>
`;
};
62 changes: 61 additions & 1 deletion lib/public/js/components/channels.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import {
import htm from "https://esm.sh/htm";
import { AddChannelMenu } from "./add-channel-menu.js";
import { ChannelAccountStatusBadge } from "./channel-account-status-badge.js";
import { ChannelLoginModal } from "./channel-login-modal.js";
import { ConfirmDialog } from "./confirm-dialog.js";
import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
import {
deleteChannelAccount,
fetchChannelAccounts,
runChannelAccountLogin,
updateChannelAccount,
} from "../lib/api.js";
import {
Expand All @@ -26,11 +28,12 @@ import { showToast } from "./toast.js";

const html = htm.bind(h);

const ALL_CHANNELS = ["telegram", "discord", "slack"];
const ALL_CHANNELS = ["telegram", "discord", "slack", "whatsapp"];
const kChannelMeta = {
telegram: { label: "Telegram", iconSrc: "/assets/icons/telegram.svg" },
discord: { label: "Discord", iconSrc: "/assets/icons/discord.svg" },
slack: { label: "Slack", iconSrc: "/assets/icons/slack.svg" },
whatsapp: { label: "WhatsApp", iconSrc: "/assets/icons/whatsapp.svg" },
};

const getChannelMeta = (channelId = "") => {
Expand Down Expand Up @@ -147,6 +150,10 @@ export const Channels = ({
const [menuOpenId, setMenuOpenId] = useState("");
const [editingAccount, setEditingAccount] = useState(null);
const [deletingAccount, setDeletingAccount] = useState(null);
const [loginAccount, setLoginAccount] = useState(null);
const [loginOutput, setLoginOutput] = useState("");
const [loginError, setLoginError] = useState("");
const [loginRunning, setLoginRunning] = useState(false);

const loadChannelAccounts = useCallback(async () => {
setLoadingAccounts(true);
Expand Down Expand Up @@ -262,6 +269,31 @@ export const Channels = ({
setSaving(false);
}
};
const handleRunChannelLogin = async () => {
if (!loginAccount) return;
setLoginRunning(true);
setLoginError("");
setLoginOutput("");
try {
const result = await runChannelAccountLogin({
provider: loginAccount.provider,
accountId: loginAccount.id,
});
const combinedOutput = [result?.stdout || "", result?.stderr || ""]
.filter(Boolean)
.join("\n\n")
.trim();
setLoginOutput(combinedOutput || "No terminal output captured.");
if (result?.completed) {
showToast("Channel linked", "success");
}
} catch (error) {
setLoginError(String(error?.message || "Could not start channel login"));
} finally {
setLoginRunning(false);
}
};

const openCreateChannelModal = (provider) => {
setMenuOpenId("");
setEditingAccount({
Expand Down Expand Up @@ -399,6 +431,20 @@ export const Channels = ({
>
Edit
</${OverflowMenuItem}>
${channelId === "whatsapp"
? html`
<${OverflowMenuItem}
onClick=${() => {
setMenuOpenId("");
setLoginAccount(accountData);
setLoginOutput("");
setLoginError("");
}}
>
Link WhatsApp (QR)
</${OverflowMenuItem}>
`
: null}
<${OverflowMenuItem}
className="text-red-300 hover:text-red-200"
onClick=${() => {
Expand Down Expand Up @@ -521,6 +567,20 @@ export const Channels = ({
setDeletingAccount(null);
}}
/>
<${ChannelLoginModal}
visible=${!!loginAccount}
loading=${loginRunning}
title=${`Link ${String(loginAccount?.name || "WhatsApp").trim()} via QR`}
output=${loginOutput}
error=${loginError}
onRun=${handleRunChannelLogin}
onClose=${() => {
if (loginRunning) return;
setLoginAccount(null);
setLoginOutput("");
setLoginError("");
}}
/>
</div>
`;
};
Expand Down
7 changes: 6 additions & 1 deletion lib/public/js/components/onboarding/welcome-config.js
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,12 @@ export const kWelcomeGroups = [
placeholder: "xapp-...",
},
],
validate: (vals) => !!(vals.TELEGRAM_BOT_TOKEN || vals.DISCORD_BOT_TOKEN || (vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)),
validate: (vals) =>
!!(
vals.TELEGRAM_BOT_TOKEN ||
vals.DISCORD_BOT_TOKEN ||
(vals.SLACK_BOT_TOKEN && vals.SLACK_APP_TOKEN)
),
},
{
id: "tools",
Expand Down
15 changes: 10 additions & 5 deletions lib/public/js/components/onboarding/welcome-pairing-step.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ const kChannelMeta = {
label: "Discord",
iconSrc: "/assets/icons/discord.svg",
},
slack: {
label: "Slack",
iconSrc: "/assets/icons/slack.svg",
},
whatsapp: {
label: "WhatsApp",
iconSrc: "/assets/icons/whatsapp.svg",
},
};

const PairingRow = ({ pairing, onApprove, onReject }) => {
Expand Down Expand Up @@ -79,7 +87,6 @@ const PairingRow = ({ pairing, onApprove, onReject }) => {
export const WelcomePairingStep = ({
channel,
pairings,
channels,
loading,
error,
onApprove,
Expand All @@ -94,15 +101,13 @@ export const WelcomePairingStep = ({
: "Channel",
iconSrc: "",
};
const channelInfo = channels?.[channel];

if (!channel) {
return html`
<div
class="bg-red-900/30 border border-red-800 rounded-xl p-3 text-red-300 text-sm"
>
Missing channel configuration. Go back and add a Telegram or Discord bot
token.
Missing channel configuration. Go back and add a channel credential.
</div>
`;
}
Expand All @@ -116,7 +121,7 @@ export const WelcomePairingStep = ({
🎉 Setup complete
</p>
<p class="text-xs text-gray-300">
Your ${channelMeta.label} channel is connected. You can switch to
Your ${channelMeta.label} channel is connected. You can switch to${" "}
${channelMeta.label} and start using your agent now.
</p>
<p class="text-xs text-gray-500 font-normal opacity-85">
Expand Down
1 change: 0 additions & 1 deletion lib/public/js/components/welcome/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export const Welcome = ({ onComplete, acVersion }) => {
? html`<${WelcomePairingStep}
channel=${state.selectedPairingChannel}
pairings=${state.pairingRequestsPoll.data || []}
channels=${state.pairingChannels}
loading=${!state.pairingStatusPoll.data}
error=${state.pairingError}
onApprove=${actions.handlePairingApprove}
Expand Down
2 changes: 1 addition & 1 deletion lib/public/js/components/welcome/use-welcome.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export const useWelcome = ({ onComplete }) => {
const pairingChannel = getPreferredPairingChannel(normalizedVals);
if (!pairingChannel) {
throw new Error(
"No Telegram or Discord bot token configured for pairing.",
"No channel credential configured for pairing.",
);
}
setVals((prev) => ({
Expand Down
9 changes: 9 additions & 0 deletions lib/public/js/lib/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -784,6 +784,15 @@ export const deleteChannelAccount = async (payload) => {
return parseJsonOrThrow(res, "Could not delete channel account");
};

export const runChannelAccountLogin = async (payload) => {
const res = await authFetch("/api/channels/accounts/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
return parseJsonOrThrow(res, "Could not run channel login");
};

export const fetchAgent = async (agentId) => {
const res = await authFetch(`/api/agents/${encodeURIComponent(String(agentId || ""))}`);
return parseJsonOrThrow(res, "Could not load agent");
Expand Down
2 changes: 1 addition & 1 deletion lib/public/js/lib/channel-provider-availability.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const kSingleAccountChannelProviders = new Set(["discord", "slack"]);
const kSingleAccountChannelProviders = new Set(["discord", "slack", "whatsapp"]);

const hasConfiguredAccounts = ({ configuredChannelMap, provider }) => {
const channelEntry = configuredChannelMap instanceof Map
Expand Down
2 changes: 1 addition & 1 deletion lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ const gmailWatchService = registerGmailRoutes({
const telegramApi = createTelegramApi(() => process.env.TELEGRAM_BOT_TOKEN);
const discordApi = createDiscordApi(() => process.env.DISCORD_BOT_TOKEN);
const slackApi = createSlackApi(() => process.env.SLACK_BOT_TOKEN);
const watchdogNotifier = createWatchdogNotifier({ telegramApi, discordApi, slackApi });
const watchdogNotifier = createWatchdogNotifier({ telegramApi, discordApi, slackApi, clawCmd });
const watchdog = createWatchdog({
clawCmd,
launchGatewayProcess,
Expand Down
Loading
Loading