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,133 +0,0 @@
import { describe, expect, it } from 'bun:test';
import {
DISCORD_CHANNEL_PLUGIN_SPEC,
hasExplicitChannelsFlag,
hasExplicitPermissionOverride,
isDiscordChannelsSessionSupported,
resolveDiscordChannelsSyncConfigDir,
resolveDiscordChannelsLaunchPlan,
} from '../../../src/channels/discord-channels-runtime';
describe('discord channels runtime planning', () => {
it('supports only native Claude default/account sessions', () => {
expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true);
expect(isDiscordChannelsSessionSupported('claude', 'account')).toBe(true);
expect(isDiscordChannelsSessionSupported('claude', 'settings')).toBe(false);
expect(isDiscordChannelsSessionSupported('droid', 'default')).toBe(false);
});
it('detects explicit channel and permission overrides', () => {
expect(hasExplicitChannelsFlag(['--channels', 'plugin:other'])).toBe(true);
expect(hasExplicitChannelsFlag([`--channels=${DISCORD_CHANNEL_PLUGIN_SPEC}`])).toBe(true);
expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false);
expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true);
expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true);
expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true);
});
it('adds the official plugin flag and optional permission bypass when eligible', () => {
const plan = resolveDiscordChannelsLaunchPlan({
args: ['--verbose'],
config: { enabled: true, unattended: true },
target: 'claude',
profileType: 'default',
bunAvailable: true,
tokenConfigured: true,
});
expect(plan.applied).toBe(true);
expect(plan.args).toEqual([
'--verbose',
'--channels',
DISCORD_CHANNEL_PLUGIN_SPEC,
'--dangerously-skip-permissions',
]);
expect(plan.appliedPermissionBypass).toBe(true);
});
it('keeps explicit permission choice and still adds the official channel when possible', () => {
const plan = resolveDiscordChannelsLaunchPlan({
args: ['--permission-mode', 'acceptEdits'],
config: { enabled: true, unattended: true },
target: 'claude',
profileType: 'account',
bunAvailable: true,
tokenConfigured: true,
});
expect(plan.applied).toBe(true);
expect(plan.args).toEqual([
'--permission-mode',
'acceptEdits',
'--channels',
DISCORD_CHANNEL_PLUGIN_SPEC,
]);
expect(plan.appliedPermissionBypass).toBe(false);
});
it('skips when the session is incompatible or prerequisites are missing', () => {
const incompatible = resolveDiscordChannelsLaunchPlan({
args: [],
config: { enabled: true, unattended: false },
target: 'claude',
profileType: 'settings',
bunAvailable: true,
tokenConfigured: true,
});
const missingBun = resolveDiscordChannelsLaunchPlan({
args: [],
config: { enabled: true, unattended: false },
target: 'claude',
profileType: 'default',
bunAvailable: false,
tokenConfigured: true,
});
const missingToken = resolveDiscordChannelsLaunchPlan({
args: [],
config: { enabled: true, unattended: false },
target: 'claude',
profileType: 'default',
bunAvailable: true,
tokenConfigured: false,
});
expect(incompatible.applied).toBe(false);
expect(incompatible.skipMessage).toContain('native Claude default/account sessions');
expect(missingBun.applied).toBe(false);
expect(missingBun.skipMessage).toContain('Bun is not installed');
expect(missingToken.applied).toBe(false);
expect(missingToken.skipMessage).toContain('DISCORD_BOT_TOKEN is not configured');
});
it('leaves explicit channel arguments untouched', () => {
const plan = resolveDiscordChannelsLaunchPlan({
args: ['--channels', 'plugin:custom'],
config: { enabled: true, unattended: true },
target: 'claude',
profileType: 'default',
bunAvailable: true,
tokenConfigured: true,
});
expect(plan.applied).toBe(false);
expect(plan.args).toEqual(['--channels', 'plugin:custom']);
expect(plan.skipMessage).toBeUndefined();
});
it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config';
try {
expect(resolveDiscordChannelsSyncConfigDir()).toBe('/tmp/external-claude-config');
expect(resolveDiscordChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit');
} finally {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
}
});
});
@@ -1,98 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
clearConfiguredDiscordBotTokenEverywhere,
clearConfiguredDiscordBotToken,
getDiscordChannelsEnvPath,
hasConfiguredDiscordBotToken,
readConfiguredDiscordBotToken,
readDiscordBotTokenFromEnvContent,
setConfiguredDiscordBotToken,
syncDiscordChannelsEnvToConfigDir,
} from '../../../src/channels/discord-channels-store';
describe('discord channels token store', () => {
let tempHome = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-discord-channels-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
process.env.HOME = tempHome;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('writes and reads DISCORD_BOT_TOKEN from the canonical Claude channels env file', () => {
const envPath = setConfiguredDiscordBotToken('discord-secret');
expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'discord', '.env'));
expect(hasConfiguredDiscordBotToken()).toBe(true);
expect(readConfiguredDiscordBotToken()).toBe('discord-secret');
expect(readDiscordBotTokenFromEnvContent(fs.readFileSync(envPath, 'utf8'))).toBe(
'discord-secret'
);
});
it('removes only the token entry and deletes the file when nothing remains', () => {
const envPath = getDiscordChannelsEnvPath();
fs.mkdirSync(path.dirname(envPath), { recursive: true });
fs.writeFileSync(envPath, '# comment\nDISCORD_BOT_TOKEN=secret\nOTHER_KEY=value\n', 'utf8');
clearConfiguredDiscordBotToken();
expect(fs.readFileSync(envPath, 'utf8')).toBe('# comment\nOTHER_KEY=value\n');
clearConfiguredDiscordBotToken();
fs.writeFileSync(envPath, 'DISCORD_BOT_TOKEN=secret\n', 'utf8');
clearConfiguredDiscordBotToken();
expect(fs.existsSync(envPath)).toBe(false);
});
it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => {
setConfiguredDiscordBotToken('discord-secret');
const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env');
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8');
const result = syncDiscordChannelsEnvToConfigDir(targetConfigDir);
expect(result.synced).toBe(true);
expect(result.targetPath).toBe(targetPath);
expect(fs.readFileSync(targetPath, 'utf8')).toBe(
'# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n'
);
expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600);
});
it('clears previously synced copies across managed Claude config dirs', () => {
setConfiguredDiscordBotToken('discord-secret');
const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env');
syncDiscordChannelsEnvToConfigDir(instanceConfigDir);
expect(fs.existsSync(instanceEnvPath)).toBe(true);
const clearedPaths = clearConfiguredDiscordBotTokenEverywhere();
expect(clearedPaths).toContain(getDiscordChannelsEnvPath());
expect(clearedPaths).toContain(instanceEnvPath);
expect(fs.existsSync(getDiscordChannelsEnvPath())).toBe(false);
expect(fs.existsSync(instanceEnvPath)).toBe(false);
});
});
@@ -0,0 +1,164 @@
import { describe, expect, it } from 'bun:test';
import {
OFFICIAL_CHANNELS,
buildOfficialChannelsArgs,
expandOfficialChannelSelection,
hasExplicitChannelsFlag,
hasExplicitPermissionOverride,
isDiscordChannelsSessionSupported,
resolveOfficialChannelsLaunchPlan,
resolveOfficialChannelsSyncConfigDir,
} from '../../../src/channels/official-channels-runtime';
describe('official channels runtime planning', () => {
it('supports only native Claude default/account sessions', () => {
expect(isDiscordChannelsSessionSupported('claude', 'default')).toBe(true);
expect(isDiscordChannelsSessionSupported('claude', 'account')).toBe(true);
expect(isDiscordChannelsSessionSupported('claude', 'settings')).toBe(false);
expect(isDiscordChannelsSessionSupported('droid', 'default')).toBe(false);
});
it('detects explicit channel and permission overrides', () => {
expect(hasExplicitChannelsFlag(['--channels', 'plugin:other'])).toBe(true);
expect(
hasExplicitChannelsFlag([`--channels=${OFFICIAL_CHANNELS.discord.pluginSpec}`])
).toBe(true);
expect(hasExplicitChannelsFlag(['--permission-mode', 'acceptEdits'])).toBe(false);
expect(hasExplicitPermissionOverride(['--dangerously-skip-permissions'])).toBe(true);
expect(hasExplicitPermissionOverride(['--permission-mode', 'acceptEdits'])).toBe(true);
expect(hasExplicitPermissionOverride(['--permission-mode=acceptEdits'])).toBe(true);
});
it('expands channel selection and builds runtime argv in stable order', () => {
expect(expandOfficialChannelSelection('all')).toEqual(['telegram', 'discord', 'imessage']);
expect(expandOfficialChannelSelection('discord,telegram')).toEqual(['telegram', 'discord']);
expect(buildOfficialChannelsArgs(['--verbose'], ['telegram', 'discord'], true)).toEqual([
'--verbose',
'--channels',
OFFICIAL_CHANNELS.telegram.pluginSpec,
OFFICIAL_CHANNELS.discord.pluginSpec,
'--dangerously-skip-permissions',
]);
});
it('adds all ready selected channels and optional permission bypass when eligible', () => {
const plan = resolveOfficialChannelsLaunchPlan({
args: ['--verbose'],
config: { selected: ['telegram', 'discord'], unattended: true },
target: 'claude',
profileType: 'default',
bunAvailable: true,
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
expect(plan.applied).toBe(true);
expect(plan.appliedChannels).toEqual(['telegram', 'discord']);
expect(plan.wantsPermissionBypass).toBe(true);
});
it('keeps explicit permission choice and still returns ready channels', () => {
const plan = resolveOfficialChannelsLaunchPlan({
args: ['--permission-mode', 'acceptEdits'],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'account',
bunAvailable: true,
channelReadiness: {
telegram: false,
discord: true,
imessage: true,
},
});
expect(plan.applied).toBe(true);
expect(plan.appliedChannels).toEqual(['discord']);
expect(plan.wantsPermissionBypass).toBe(false);
});
it('skips incompatible sessions and reports per-channel readiness problems', () => {
const incompatible = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['discord'], unattended: false },
target: 'claude',
profileType: 'settings',
bunAvailable: true,
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
const missingBun = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['discord'], unattended: false },
target: 'claude',
profileType: 'default',
bunAvailable: false,
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
const missingToken = resolveOfficialChannelsLaunchPlan({
args: [],
config: { selected: ['telegram', 'discord'], unattended: false },
target: 'claude',
profileType: 'default',
bunAvailable: true,
channelReadiness: {
telegram: false,
discord: true,
imessage: true,
},
});
expect(incompatible.applied).toBe(false);
expect(incompatible.skippedMessages.join(' ')).toContain('native Claude default/account sessions');
expect(missingBun.applied).toBe(false);
expect(missingBun.skippedMessages.join(' ')).toContain('Bun is not installed');
expect(missingToken.applied).toBe(true);
expect(missingToken.appliedChannels).toEqual(['discord']);
expect(missingToken.skippedMessages.join(' ')).toContain('TELEGRAM_BOT_TOKEN is not configured');
});
it('leaves explicit channel arguments untouched', () => {
const plan = resolveOfficialChannelsLaunchPlan({
args: ['--channels', 'plugin:custom'],
config: { selected: ['discord'], unattended: true },
target: 'claude',
profileType: 'default',
bunAvailable: true,
channelReadiness: {
telegram: true,
discord: true,
imessage: true,
},
});
expect(plan.applied).toBe(false);
expect(plan.appliedChannels).toEqual([]);
expect(plan.skippedMessages).toEqual([]);
});
it('falls back to process.env.CLAUDE_CONFIG_DIR for sync when no explicit dir is passed', () => {
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = '/tmp/external-claude-config';
try {
expect(resolveOfficialChannelsSyncConfigDir()).toBe('/tmp/external-claude-config');
expect(resolveOfficialChannelsSyncConfigDir('/tmp/explicit')).toBe('/tmp/explicit');
} finally {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
}
});
});
@@ -0,0 +1,114 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
clearConfiguredOfficialChannelToken,
clearConfiguredOfficialChannelTokensEverywhere,
getOfficialChannelEnvPath,
hasConfiguredOfficialChannelToken,
readConfiguredOfficialChannelToken,
readOfficialChannelTokenFromEnvContent,
setConfiguredOfficialChannelToken,
syncOfficialChannelEnvToConfigDir,
} from '../../../src/channels/official-channels-store';
describe('official channels token store', () => {
let tempHome = '';
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-discord-channels-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
process.env.HOME = tempHome;
process.env.CCS_HOME = tempHome;
});
afterEach(() => {
if (originalHome !== undefined) process.env.HOME = originalHome;
else delete process.env.HOME;
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('writes and reads DISCORD_BOT_TOKEN from the canonical Discord env file', () => {
const envPath = setConfiguredOfficialChannelToken('discord', 'discord-secret');
expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'discord', '.env'));
expect(hasConfiguredOfficialChannelToken('discord')).toBe(true);
expect(readConfiguredOfficialChannelToken('discord')).toBe('discord-secret');
expect(readOfficialChannelTokenFromEnvContent('discord', fs.readFileSync(envPath, 'utf8'))).toBe(
'discord-secret'
);
});
it('writes and reads TELEGRAM_BOT_TOKEN from the canonical Telegram env file', () => {
const envPath = setConfiguredOfficialChannelToken('telegram', 'telegram-secret');
expect(envPath).toBe(path.join(tempHome, '.claude', 'channels', 'telegram', '.env'));
expect(hasConfiguredOfficialChannelToken('telegram')).toBe(true);
expect(readConfiguredOfficialChannelToken('telegram')).toBe('telegram-secret');
});
it('removes only the channel token entry and deletes the file when nothing remains', () => {
const envPath = getOfficialChannelEnvPath('discord');
fs.mkdirSync(path.dirname(envPath), { recursive: true });
fs.writeFileSync(envPath, '# comment\nDISCORD_BOT_TOKEN=secret\nOTHER_KEY=value\n', 'utf8');
clearConfiguredOfficialChannelToken('discord');
expect(fs.readFileSync(envPath, 'utf8')).toBe('# comment\nOTHER_KEY=value\n');
clearConfiguredOfficialChannelToken('discord');
fs.writeFileSync(envPath, 'DISCORD_BOT_TOKEN=secret\n', 'utf8');
clearConfiguredOfficialChannelToken('discord');
expect(fs.existsSync(envPath)).toBe(false);
});
it('syncs the canonical env file into an alternate CLAUDE_CONFIG_DIR for account sessions', () => {
setConfiguredOfficialChannelToken('discord', 'discord-secret');
const targetConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const targetPath = path.join(targetConfigDir, 'channels', 'discord', '.env');
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, '# keep\nOTHER_KEY=value\n', 'utf8');
const result = syncOfficialChannelEnvToConfigDir('discord', targetConfigDir);
expect(result.synced).toBe(true);
expect(result.targetPath).toBe(targetPath);
expect(fs.readFileSync(targetPath, 'utf8')).toBe(
'# keep\nOTHER_KEY=value\n\nDISCORD_BOT_TOKEN=discord-secret\n'
);
expect(fs.statSync(targetPath).mode & 0o777).toBe(0o600);
});
it('clears previously synced copies across managed Claude config dirs', () => {
setConfiguredOfficialChannelToken('discord', 'discord-secret');
setConfiguredOfficialChannelToken('telegram', 'telegram-secret');
const instanceConfigDir = path.join(tempHome, '.ccs', 'instances', 'work');
const instanceEnvPath = path.join(instanceConfigDir, 'channels', 'discord', '.env');
const telegramEnvPath = path.join(instanceConfigDir, 'channels', 'telegram', '.env');
syncOfficialChannelEnvToConfigDir('discord', instanceConfigDir);
syncOfficialChannelEnvToConfigDir('telegram', instanceConfigDir);
expect(fs.existsSync(instanceEnvPath)).toBe(true);
expect(fs.existsSync(telegramEnvPath)).toBe(true);
const clearedPaths = clearConfiguredOfficialChannelTokensEverywhere();
expect(clearedPaths).toContain(getOfficialChannelEnvPath('discord'));
expect(clearedPaths).toContain(getOfficialChannelEnvPath('telegram'));
expect(clearedPaths).toContain(instanceEnvPath);
expect(clearedPaths).toContain(telegramEnvPath);
expect(fs.existsSync(getOfficialChannelEnvPath('discord'))).toBe(false);
expect(fs.existsSync(getOfficialChannelEnvPath('telegram'))).toBe(false);
expect(fs.existsSync(instanceEnvPath)).toBe(false);
expect(fs.existsSync(telegramEnvPath)).toBe(false);
});
});
@@ -2,26 +2,36 @@ import { describe, expect, it } from 'bun:test';
import { parseChannelsCommandArgs } from '../../../src/commands/config-channels-command';
describe('config channels command parser', () => {
it('parses toggles and token input', () => {
it('parses selection, unattended mode, and token input', () => {
const result = parseChannelsCommandArgs([
'--enable',
'--set',
'telegram,discord',
'--unattended',
'--set-token',
'discord-secret',
'telegram=telegram-secret',
]);
expect(result.enable).toBe(true);
expect(result.setSelection).toBe('telegram,discord');
expect(result.unattended).toBe(true);
expect(result.setToken).toBe('discord-secret');
expect(result.setToken).toEqual({
channelId: 'telegram',
token: 'telegram-secret',
});
});
it('supports inline token assignment and clear flags', () => {
const result = parseChannelsCommandArgs(['--disable', '--no-unattended', '--set-token=abc']);
const clearResult = parseChannelsCommandArgs(['--clear-token']);
it('supports inline token assignment, legacy flags, and clear-token variants', () => {
const result = parseChannelsCommandArgs([
'--disable',
'--no-unattended',
'--set-token=abc',
]);
const clearAll = parseChannelsCommandArgs(['--clear-token']);
const clearOne = parseChannelsCommandArgs(['--clear-token', 'discord']);
expect(result.disable).toBe(true);
expect(result.noUnattended).toBe(true);
expect(result.setToken).toBe('abc');
expect(clearResult.clearToken).toBe(true);
expect(result.setToken).toEqual({ channelId: 'discord', token: 'abc' });
expect(clearAll.clearTokenAll).toBe(true);
expect(clearOne.clearTokenChannel).toBe('discord');
});
});
+3 -3
View File
@@ -106,10 +106,10 @@ describe('unified-config-types', () => {
expect(config.preferences.auto_update).toBe(true);
});
it('should default Discord Channels to disabled and attended mode', () => {
it('should default Official Channels to disabled and attended mode', () => {
const config = createEmptyUnifiedConfig();
expect(config.discord_channels?.enabled).toBe(false);
expect(config.discord_channels?.unattended).toBe(false);
expect(config.channels?.selected).toEqual([]);
expect(config.channels?.unattended).toBe(false);
});
it('should have CLIProxy providers list', () => {