feat(channels): support telegram and imessage

- replace the Discord-only config with multi-channel official channels selection

- add Telegram token handling and iMessage platform-aware runtime gating

- expand CLI, dashboard, and tests for Telegram, Discord, and iMessage

Refs #783
This commit is contained in:
Tam Nhu Tran committed 2026-03-25 16:31:55 -04:00
1 parent 4bce1559dd
commit 6f1f032c63
19 files changed
+1416 -725

No files matched your search

@@ -1,14 +1,14 @@
import { useCallback, useState } from 'react';
import type { DiscordChannelsConfig, DiscordChannelsStatus } from '../types';
import type { OfficialChannelId, OfficialChannelsConfig, OfficialChannelsStatus } from '../types';
const DEFAULT_CONFIG: DiscordChannelsConfig = {
enabled: false,
const DEFAULT_CONFIG: OfficialChannelsConfig = {
selected: [],
unattended: false,
};
export function useDiscordChannelsConfig() {
const [config, setConfig] = useState<DiscordChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<DiscordChannelsStatus | null>(null);
export function useOfficialChannelsConfig() {
const [config, setConfig] = useState<OfficialChannelsConfig>(DEFAULT_CONFIG);
const [status, setStatus] = useState<OfficialChannelsStatus | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -25,12 +25,12 @@ export function useDiscordChannelsConfig() {
setError(null);
const res = await fetch('/api/channels');
if (!res.ok) {
throw new Error('Failed to load Discord Channels settings');
throw new Error('Failed to load Official Channels settings');
}
const data = (await res.json()) as {
config?: DiscordChannelsConfig;
status?: DiscordChannelsStatus;
config?: OfficialChannelsConfig;
status?: OfficialChannelsStatus;
};
setConfig(data.config ?? DEFAULT_CONFIG);
@@ -43,7 +43,7 @@ export function useDiscordChannelsConfig() {
}, []);
const updateConfig = useCallback(
async (updates: Partial<DiscordChannelsConfig>, successMessage = 'Settings saved') => {
async (updates: Partial<OfficialChannelsConfig>, successMessage = 'Settings saved') => {
try {
setSaving(true);
setError(null);
@@ -56,10 +56,10 @@ export function useDiscordChannelsConfig() {
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || 'Failed to save Discord Channels settings');
throw new Error(data.error || 'Failed to save Official Channels settings');
}
const data = (await res.json()) as { config?: DiscordChannelsConfig };
const data = (await res.json()) as { config?: OfficialChannelsConfig };
setConfig(data.config ?? { ...config, ...updates });
flashSuccess(successMessage);
} catch (err) {
@@ -72,12 +72,12 @@ export function useDiscordChannelsConfig() {
);
const saveToken = useCallback(
async (token: string) => {
async (channelId: OfficialChannelId, token: string) => {
try {
setSaving(true);
setError(null);
const res = await fetch('/api/channels/discord/token', {
const res = await fetch(`/api/channels/${channelId}/token`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
@@ -85,11 +85,11 @@ export function useDiscordChannelsConfig() {
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || 'Failed to save Discord bot token');
throw new Error(data.error || `Failed to save ${channelId} token`);
}
await fetchConfig();
flashSuccess('Discord bot token saved');
flashSuccess(`${channelId} token saved`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
@@ -99,28 +99,31 @@ export function useDiscordChannelsConfig() {
[fetchConfig, flashSuccess]
);
const clearToken = useCallback(async () => {
try {
setSaving(true);
setError(null);
const clearToken = useCallback(
async (channelId: OfficialChannelId) => {
try {
setSaving(true);
setError(null);
const res = await fetch('/api/channels/discord/token', {
method: 'DELETE',
});
const res = await fetch(`/api/channels/${channelId}/token`, {
method: 'DELETE',
});
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || 'Failed to clear Discord bot token');
if (!res.ok) {
const data = (await res.json()) as { error?: string };
throw new Error(data.error || `Failed to clear ${channelId} token`);
}
await fetchConfig();
flashSuccess(`${channelId} token cleared`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setSaving(false);
}
await fetchConfig();
flashSuccess('Discord bot token cleared');
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setSaving(false);
}
}, [fetchConfig, flashSuccess]);
},
[fetchConfig, flashSuccess]
);
return {
config,
+141 -122
View File
@@ -7,7 +7,6 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Switch } from '@/components/ui/switch';
import {
AlertCircle,
Bot,
CheckCircle2,
MessageSquare,
RefreshCw,
@@ -15,8 +14,17 @@ import {
ShieldAlert,
Trash2,
} from 'lucide-react';
import { useDiscordChannelsConfig } from '../hooks/use-discord-channels-config';
import { useOfficialChannelsConfig } from '../hooks/use-official-channels-config';
import { useRawConfig } from '../hooks';
import type { OfficialChannelId } from '../types';
type TokenDrafts = Record<OfficialChannelId, string>;
const EMPTY_DRAFTS: TokenDrafts = {
telegram: '',
discord: '',
imessage: '',
};
export default function ChannelsSection() {
const {
@@ -30,9 +38,9 @@ export default function ChannelsSection() {
updateConfig,
saveToken,
clearToken,
} = useDiscordChannelsConfig();
} = useOfficialChannelsConfig();
const { fetchRawConfig } = useRawConfig();
const [tokenDraft, setTokenDraft] = useState('');
const [tokenDrafts, setTokenDrafts] = useState<TokenDrafts>(EMPTY_DRAFTS);
useEffect(() => {
void fetchConfig();
@@ -43,31 +51,39 @@ export default function ChannelsSection() {
await Promise.all([fetchConfig(), fetchRawConfig()]);
};
const handleToggle = async (
updates: Partial<typeof config>,
successMessage: string
): Promise<void> => {
await updateConfig(updates, successMessage);
const toggleChannel = async (channelId: OfficialChannelId, checked: boolean): Promise<void> => {
const nextSelected = checked
? [...new Set([...config.selected, channelId])]
: config.selected.filter((value) => value !== channelId);
await updateConfig(
{ selected: nextSelected },
checked ? `${channelId} enabled` : `${channelId} disabled`
);
await fetchRawConfig();
};
const handleSaveToken = async (): Promise<void> => {
await saveToken(tokenDraft);
setTokenDraft('');
const updateTokenDraft = (channelId: OfficialChannelId, value: string) => {
setTokenDrafts((current) => ({ ...current, [channelId]: value }));
};
const handleSaveToken = async (channelId: OfficialChannelId): Promise<void> => {
await saveToken(channelId, tokenDrafts[channelId]);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
};
const handleClearToken = async (): Promise<void> => {
await clearToken();
setTokenDraft('');
const handleClearToken = async (channelId: OfficialChannelId): Promise<void> => {
await clearToken(channelId);
setTokenDrafts((current) => ({ ...current, [channelId]: '' }));
await fetchRawConfig();
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-1 items-center justify-center">
<div className="flex items-center gap-3 text-muted-foreground">
<RefreshCw className="w-5 h-5 animate-spin" />
<RefreshCw className="h-5 w-5 animate-spin" />
<span>Loading</span>
</div>
</div>
@@ -79,8 +95,8 @@ export default function ChannelsSection() {
<div
className={`absolute left-5 right-5 top-20 z-10 transition-all duration-200 ease-out ${
error || success
? 'opacity-100 translate-y-0'
: 'opacity-0 -translate-y-2 pointer-events-none'
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-2 opacity-0'
}`}
>
{error && (
@@ -90,7 +106,7 @@ export default function ChannelsSection() {
</Alert>
)}
{success && (
<div className="flex items-center gap-2 px-3 py-2 rounded-md border border-green-200 bg-green-50 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<div className="flex items-center gap-2 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-green-700 shadow-lg dark:border-green-900/50 dark:bg-green-900/90 dark:text-green-300">
<CheckCircle2 className="h-4 w-4 shrink-0" />
<span className="text-sm font-medium">{success}</span>
</div>
@@ -98,20 +114,22 @@ export default function ChannelsSection() {
</div>
<ScrollArea className="flex-1">
<div className="p-5 space-y-6">
<div className="space-y-6 p-5">
<div className="flex items-center gap-3">
<MessageSquare className="w-5 h-5 text-primary" />
<MessageSquare className="h-5 w-5 text-primary" />
<p className="text-sm text-muted-foreground">
Auto-enable Anthropic&apos;s official Discord Channels plugin for compatible Claude
sessions. CCS stores only the booleans in <code>config.yaml</code>; the bot token
stays in Claude&apos;s official channels env file.
Auto-enable Anthropic&apos;s official Claude channels for compatible native Claude
sessions. CCS stores only channel selection in <code>config.yaml</code>; bot tokens
stay in Claude&apos;s per-channel env files.
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-lg border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-wide text-muted-foreground">Runtime</p>
<p className="mt-1 font-medium">{status?.pluginSpec ?? 'Unknown plugin'}</p>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Selected</p>
<p className="mt-1 font-medium">
{config.selected.length > 0 ? config.selected.join(', ') : 'None'}
</p>
<p className="mt-2 text-sm text-muted-foreground">
Applies only to native Claude <code>default</code> and <code>account</code>{' '}
sessions.
@@ -125,48 +143,23 @@ export default function ChannelsSection() {
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span>Bot token</span>
<span className={status?.tokenConfigured ? 'text-green-600' : 'text-amber-600'}>
{status?.tokenConfigured ? 'Configured' : 'Not configured'}
</span>
<span>Supported profiles</span>
<span>{status?.supportedProfiles.join(', ')}</span>
</div>
<div className="text-xs text-muted-foreground break-all">{status?.tokenPath}</div>
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<Label className="text-base font-medium">Enable Discord Channels on launch</Label>
<p className="text-sm text-muted-foreground mt-1">
When enabled, CCS appends the official Discord Channels plugin at runtime unless
you already passed your own <code>--channels</code> flag.
</p>
</div>
<Switch
checked={config.enabled}
disabled={saving}
onCheckedChange={(checked) =>
void handleToggle(
{ enabled: checked },
checked
? 'Discord Channels auto-enable enabled'
: 'Discord Channels auto-enable disabled'
)
}
/>
</div>
<div className="rounded-lg border p-4">
<div className="flex items-start justify-between gap-4 rounded-lg bg-muted/30 p-4">
<div className="flex gap-3">
<ShieldAlert className="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
<div>
<Label className="text-sm font-medium">
Also add <code>--dangerously-skip-permissions</code>
</Label>
<p className="text-sm text-muted-foreground mt-1">
Opt-in only. CCS adds the bypass flag only when it is auto-enabling Discord
Channels and you did not already set a permission flag yourself.
<p className="mt-1 text-sm text-muted-foreground">
Opt-in only. CCS adds the bypass flag once when at least one selected channel
is being auto-enabled and you did not already pass a permission flag yourself.
</p>
</div>
</div>
@@ -174,83 +167,109 @@ export default function ChannelsSection() {
checked={config.unattended}
disabled={saving}
onCheckedChange={(checked) =>
void handleToggle(
void updateConfig(
{ unattended: checked },
checked
? 'Unattended Discord Channels enabled'
: 'Unattended Discord Channels disabled'
checked ? 'Unattended mode enabled' : 'Unattended mode disabled'
)
}
/>
</div>
</div>
<div className="rounded-lg border p-4 space-y-4">
<div className="flex items-center gap-2">
<Bot className="w-4 h-4 text-primary" />
<Label className="text-base font-medium">Discord bot token</Label>
</div>
<p className="text-sm text-muted-foreground">
Save <code>DISCORD_BOT_TOKEN</code> into Claude&apos;s official Discord channel env
file. The dashboard never reads the token value back after save.
</p>
<Input
type="password"
value={tokenDraft}
onChange={(event) => setTokenDraft(event.target.value)}
placeholder={
status?.tokenConfigured
? 'Configured. Enter a new token to replace it.'
: 'Paste DISCORD_BOT_TOKEN'
}
disabled={saving}
/>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => void handleSaveToken()}
disabled={saving || !tokenDraft.trim()}
>
<Save className="w-4 h-4 mr-2" />
Save Token
</Button>
<Button
variant="outline"
onClick={() => void handleClearToken()}
disabled={saving || !status?.tokenConfigured}
>
<Trash2 className="w-4 h-4 mr-2" />
Clear Token
</Button>
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<RefreshCw className={`w-4 h-4 mr-2 ${saving ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<div className="space-y-4">
{status?.channels.map((channel) => {
const enabled = config.selected.includes(channel.id);
const tokenDraft = tokenDrafts[channel.id];
return (
<div key={channel.id} className="rounded-lg border p-4 space-y-4">
<div className="flex items-start justify-between gap-4">
<div>
<Label className="text-base font-medium">{channel.displayName}</Label>
<p className="mt-1 text-sm text-muted-foreground">{channel.summary}</p>
<p className="mt-2 font-mono text-xs text-muted-foreground">
{channel.pluginSpec}
</p>
{channel.unavailableReason && (
<p className="mt-2 text-sm text-amber-600">{channel.unavailableReason}</p>
)}
</div>
<Switch
checked={enabled}
disabled={saving || Boolean(channel.unavailableReason)}
onCheckedChange={(checked) => void toggleChannel(channel.id, checked)}
/>
</div>
{channel.requiresToken && (
<div className="space-y-3 rounded-lg bg-muted/30 p-4">
<p className="text-sm text-muted-foreground">
Save <code>{channel.envKey}</code> in Claude&apos;s official channel env
file. The dashboard never reads the token value back after save.
</p>
<Input
type="password"
value={tokenDraft}
onChange={(event) => updateTokenDraft(channel.id, event.target.value)}
placeholder={
channel.tokenConfigured
? `Configured. Enter a new ${channel.envKey} to replace it.`
: `Paste ${channel.envKey}`
}
disabled={saving}
/>
<div className="text-xs text-muted-foreground break-all">
{channel.tokenPath}
</div>
<div className="flex flex-wrap gap-2">
<Button
onClick={() => void handleSaveToken(channel.id)}
disabled={saving || !tokenDraft.trim()}
>
<Save className="mr-2 h-4 w-4" />
Save Token
</Button>
<Button
variant="outline"
onClick={() => void handleClearToken(channel.id)}
disabled={saving || !channel.tokenConfigured}
>
<Trash2 className="mr-2 h-4 w-4" />
Clear Token
</Button>
</div>
</div>
)}
<div className="space-y-2">
<p className="text-sm font-medium">Claude-side setup</p>
{(channel.manualSetupCommands ?? []).map((command) => (
<div
key={command}
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
>
{command}
</div>
))}
</div>
</div>
);
})}
</div>
<Alert>
<AlertDescription>
CCS does not persist a global Claude setting for channels. It only prepares the token
file and injects runtime flags when the session is compatible and the prerequisites
are present.
CCS does not persist a global Claude setting for channels. It only prepares channel
env files and injects runtime flags when the selected channels are compatible and
ready.
</AlertDescription>
</Alert>
<div className="rounded-lg border p-4 space-y-3">
<Label className="text-base font-medium">Claude-side setup</Label>
<p className="text-sm text-muted-foreground">
If the plugin is not ready yet, complete the official setup once inside Claude.
</p>
<div className="space-y-2">
{(status?.manualSetupCommands ?? []).map((command) => (
<div
key={command}
className="rounded-md bg-muted px-3 py-2 font-mono text-sm break-all"
>
{command}
</div>
))}
</div>
<div className="flex justify-end">
<Button variant="outline" onClick={() => void refreshAll()} disabled={saving}>
<RefreshCw className={`mr-2 h-4 w-4 ${saving ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
</div>
</ScrollArea>
+20 -8
View File
@@ -60,22 +60,34 @@ export interface GlobalEnvConfig {
env: Record<string, string>;
}
// === Discord Channels Types ===
// === Official Channels Types ===
export interface DiscordChannelsConfig {
enabled: boolean;
export type OfficialChannelId = 'telegram' | 'discord' | 'imessage';
export interface OfficialChannelsConfig {
selected: OfficialChannelId[];
unattended: boolean;
}
export interface DiscordChannelsStatus {
bunInstalled: boolean;
tokenConfigured: boolean;
tokenPath: string;
export interface OfficialChannelStatus {
id: OfficialChannelId;
displayName: string;
pluginSpec: string;
supportedProfiles: string[];
summary: string;
requiresToken: boolean;
envKey?: string;
tokenConfigured: boolean;
tokenPath?: string;
unavailableReason?: string;
manualSetupCommands: string[];
}
export interface OfficialChannelsStatus {
bunInstalled: boolean;
supportedProfiles: string[];
channels: OfficialChannelStatus[];
}
// === Tab Types ===
export type SettingsTab =