Skip to content
Merged
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
19 changes: 18 additions & 1 deletion app/src/components/settings/panels/MessagingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,24 @@ import ChannelSetupModal from '../../channels/ChannelSetupModal';
import SettingsHeader from '../components/SettingsHeader';
import { useSettingsNavigation } from '../hooks/useSettingsNavigation';

const CHANNEL_ICONS: Record<string, string> = { telegram: '✈️', discord: '🎮', web: '🌐' };
/**
* Mapping from `ChannelDefinition.icon` slugs to the emoji rendered next to
* each channel in the Messaging settings panel. Exported so unit tests can
* assert against it without rendering the full panel (the panel pulls in
* Redux, i18n, routing, and `useChannelDefinitions`, all of which make a
* focused render test more expensive than a direct mapping assertion).
* Keep in sync with the icon slugs returned by the backend
* `channels::controllers::definitions::all_channel_definitions`.
*/
export const CHANNEL_ICONS: Record<string, string> = {
telegram: '✈️',
discord: '🎮',
web: '🌐',
// Lark (国际版) / Feishu (中国版) — same backend, single icon. See #2048.
lark: '🪶',
// DingTalk (钉钉). See #2048.
dingtalk: '🔔',
};

function statusDot(status: ChannelConnectionStatus): string {
switch (status) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';

import { CHANNEL_ICONS } from '../MessagingPanel';

describe('MessagingPanel CHANNEL_ICONS', () => {
it('includes icons for every shipped channel slug', () => {
// The backend `channels::controllers::definitions::all_channel_definitions`
// emits these icon slugs; the Messaging panel must render an emoji for
// each or the channel row gets a blank gap. Pin them by key so a renamed
// slug on either side fails this test instead of a silent visual break.
expect(CHANNEL_ICONS.telegram).toBe('✈️');
expect(CHANNEL_ICONS.discord).toBe('🎮');
expect(CHANNEL_ICONS.web).toBe('🌐');
});

it('includes Lark/Feishu and DingTalk icons (#2048)', () => {
// Regression for #2048 — adding the channel definitions without the
// matching icon entries produced a blank chip next to each channel in
// the Messaging settings panel.
expect(CHANNEL_ICONS.lark).toBe('🪶');
expect(CHANNEL_ICONS.dingtalk).toBe('🔔');
});

it('has no duplicate emoji values (icons remain visually distinct)', () => {
// Two channels sharing the same emoji would make their rows visually
// indistinguishable in the panel. Asserting uniqueness here catches
// the easy copy-paste mistake at test time.
const values = Object.values(CHANNEL_ICONS);
const unique = new Set(values);
expect(unique.size).toBe(values.length);
});
});
118 changes: 118 additions & 0 deletions app/src/lib/channels/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,4 +118,122 @@ export const FALLBACK_DEFINITIONS: ChannelDefinition[] = [
],
capabilities: ['send_text', 'send_rich_text', 'receive_text'],
},
// Lark / Feishu — fields must stay aligned with `LarkConfig` in
// `src/openhuman/config/schema/channels.rs` and `lark_definition()` in
// `src/openhuman/channels/controllers/definitions.rs`. See #2048.
{
id: 'lark',
display_name: 'Lark / Feishu',
description: 'Send and receive via Lark (international) or Feishu (中国版).',
icon: 'lark',
auth_modes: [
{
mode: 'api_key',
description: 'Provide your Lark/Feishu app credentials from the Open Platform.',
fields: [
{
key: 'app_id',
label: 'App ID',
field_type: 'string',
required: true,
placeholder: 'cli_xxxxxxxxxxxx',
},
{
key: 'app_secret',
label: 'App Secret',
field_type: 'secret',
required: true,
placeholder: 'Your Lark app secret',
},
{
key: 'encrypt_key',
label: 'Encrypt Key',
field_type: 'secret',
required: false,
placeholder: 'Optional — required only if you enabled message encryption',
},
{
key: 'verification_token',
label: 'Verification Token',
field_type: 'secret',
required: false,
placeholder: 'Optional — used for HTTP webhook verification',
},
{
key: 'use_feishu',
label: 'Use Feishu (中国版)',
field_type: 'boolean',
required: false,
placeholder: 'On = open.feishu.cn (China); off = open.larksuite.com',
},
{
key: 'receive_mode',
label: 'Receive Mode',
field_type: 'string',
required: false,
placeholder: 'websocket (default) or webhook',
},
{
key: 'port',
label: 'Webhook Port',
// Numeric — field_type stays 'string' because the schema-driven
// form renderer only accepts 'string' | 'secret' | 'boolean'.
// LarkConfig parses it back to u16. Keep aligned with the Rust
// lark_definition() entry.
field_type: 'string',
required: false,
placeholder: 'Optional — local HTTP port when receive_mode = webhook (e.g. 8080)',
},
{
key: 'allowed_users',
label: 'Allowed Users',
field_type: 'string',
required: false,
placeholder: 'Comma-separated open_id / union_id; leave empty to allow any',
},
],
auth_action: undefined,
},
],
capabilities: ['send_text', 'receive_text', 'threaded_replies'],
},
// DingTalk (钉钉) — fields must stay aligned with `DingTalkConfig` in
// `src/openhuman/config/schema/channels.rs`. See #2048.
{
id: 'dingtalk',
display_name: 'DingTalk (钉钉)',
description: 'Send and receive via DingTalk Stream Mode (钉钉).',
icon: 'dingtalk',
auth_modes: [
{
mode: 'api_key',
description: 'Provide your DingTalk app credentials from the developer console.',
fields: [
{
key: 'client_id',
label: 'Client ID (AppKey)',
field_type: 'string',
required: true,
placeholder: 'ding_xxxxxxxxxxxx',
},
{
key: 'client_secret',
label: 'Client Secret (AppSecret)',
field_type: 'secret',
required: true,
placeholder: 'Your DingTalk app secret',
},
{
key: 'allowed_users',
label: 'Allowed Users',
field_type: 'string',
required: false,
placeholder: 'Comma-separated DingTalk userIds; leave empty to allow any',
},
],
auth_action: undefined,
},
],
capabilities: ['send_text', 'receive_text'],
},
];
38 changes: 38 additions & 0 deletions app/src/store/__tests__/channelConnectionsSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,44 @@ describe('channelConnectionsSlice', () => {
const state = reducer(undefined, completeBreakingMigration());
expect(state.migrationCompleted).toBe(true);
expect(state.defaultMessagingChannel).toBe('telegram');
// Migration must reset every channel in ChannelType so subsequent
// upsert/setStatus/disconnect actions never crash on `state.connections
// [channel]` being undefined for users rehydrating persisted state
// from before #2048 added lark + dingtalk. See CoderRabbit review on
// PR #2083.
expect(state.connections.telegram).toBeDefined();
expect(state.connections.discord).toBeDefined();
expect(state.connections.web).toBeDefined();
expect(state.connections.lark).toBeDefined();
expect(state.connections.dingtalk).toBeDefined();
});

it('upsert on a newly-introduced channel does not crash after migration (#2083)', () => {
// Regression for the persisted-state crash CoderRabbit flagged:
// before this fix, an old user who had `migrationCompleted: true` in
// redux-persist but no `connections.lark` key would crash on the
// first call to upsertChannelConnection for lark.
const migrated = reducer(undefined, completeBreakingMigration());
const next = reducer(
migrated,
upsertChannelConnection({
channel: 'lark',
authMode: 'api_key',
patch: { status: 'connected', capabilities: ['send_text'] },
})
);
expect(next.connections.lark.api_key?.status).toBe('connected');
expect(next.connections.lark.api_key?.capabilities).toEqual(['send_text']);

const next2 = reducer(
migrated,
upsertChannelConnection({
channel: 'dingtalk',
authMode: 'api_key',
patch: { status: 'connected', capabilities: ['send_text'] },
})
);
expect(next2.connections.dingtalk.api_key?.status).toBe('connected');
});

it('sets default messaging channel', () => {
Expand Down
12 changes: 12 additions & 0 deletions app/src/store/channelConnectionsSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ const initialState: ChannelConnectionsState = {
telegram: makeEmptyChannelModes(),
discord: makeEmptyChannelModes(),
web: makeEmptyChannelModes(),
// Required by `ChannelType` after #2048 widened the union. Empty
// entries keep the `Record<ChannelType, …>` total — runtime state
// populates them when the user wires up credentials.
lark: makeEmptyChannelModes(),
dingtalk: makeEmptyChannelModes(),
},
};

Expand Down Expand Up @@ -55,6 +60,13 @@ const channelConnectionsSlice = createSlice({
state.connections.telegram = makeEmptyChannelModes();
state.connections.discord = makeEmptyChannelModes();
state.connections.web = makeEmptyChannelModes();
// After #2048 widened ChannelType, redux-persist rehydrated states
// from before the channels existed wouldn't have these keys; without
// explicit initialisation here, the first `upsertChannelConnection`
// for either channel would crash on `state.connections[channel]`
// being undefined. Pin them by default so the migration is total.
state.connections.lark = makeEmptyChannelModes();
state.connections.dingtalk = makeEmptyChannelModes();
state.defaultMessagingChannel = 'telegram';
state.migrationCompleted = true;
state.schemaVersion = SCHEMA_VERSION;
Expand Down
2 changes: 1 addition & 1 deletion app/src/types/channels.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type ChannelType = 'telegram' | 'discord' | 'web';
export type ChannelType = 'telegram' | 'discord' | 'web' | 'lark' | 'dingtalk';

export type ChannelAuthMode = 'managed_dm' | 'oauth' | 'bot_token' | 'api_key';

Expand Down
Loading
Loading