mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Claude Opus 4.8
Steven Enamakel
parent
9198444ffd
commit
6ce4f52972
@@ -154,7 +154,10 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
|||||||
|
|
||||||
const credentials: Record<string, string> = {};
|
const credentials: Record<string, string> = {};
|
||||||
for (const field of spec.fields) {
|
for (const field of spec.fields) {
|
||||||
const val = fieldValues[key]?.[field.key]?.trim() ?? '';
|
// `rawVal` is `undefined` only when the user never touched the field;
|
||||||
|
// an empty string means they entered something and then cleared it.
|
||||||
|
const rawVal = fieldValues[key]?.[field.key];
|
||||||
|
const val = rawVal?.trim() ?? '';
|
||||||
if (field.required && !val) {
|
if (field.required && !val) {
|
||||||
dispatch(
|
dispatch(
|
||||||
setChannelConnectionStatus({
|
setChannelConnectionStatus({
|
||||||
@@ -169,7 +172,16 @@ const DiscordConfig = ({ definition }: DiscordConfigProps) => {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (val) credentials[field.key] = val;
|
if (val) {
|
||||||
|
credentials[field.key] = val;
|
||||||
|
} else if (rawVal !== undefined) {
|
||||||
|
// Field was edited and then cleared — submit an explicit empty value
|
||||||
|
// instead of omitting it, so the backend can distinguish "cleared"
|
||||||
|
// from "never entered". For the allowlist this is what makes clearing
|
||||||
|
// it on reconnect mean "allow everyone" rather than silently reusing
|
||||||
|
// the previously-saved list (#3794 review — Codex P2).
|
||||||
|
credentials[field.key] = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await channelConnectionsApi.connectChannel('discord', {
|
const result = await channelConnectionsApi.connectChannel('discord', {
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ describe('DiscordConfig', () => {
|
|||||||
renderWithProviders(<DiscordConfig definition={discordDef} />);
|
renderWithProviders(<DiscordConfig definition={discordDef} />);
|
||||||
expect(screen.getByPlaceholderText(/Your Discord bot token/)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/Your Discord bot token/)).toBeInTheDocument();
|
||||||
expect(screen.getByPlaceholderText(/restrict to a specific server/)).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/restrict to a specific server/)).toBeInTheDocument();
|
||||||
|
// Issue #3763: the allowlist must be settable in the connect UI.
|
||||||
|
expect(screen.getByPlaceholderText(/Discord user IDs, or \* for everyone/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Connect buttons for each auth mode', () => {
|
it('shows Connect buttons for each auth mode', () => {
|
||||||
@@ -63,6 +65,55 @@ describe('DiscordConfig', () => {
|
|||||||
expect(connectButtons.length).toBe(3);
|
expect(connectButtons.length).toBe(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// #3794 review (Codex P2): clearing the allowlist must reach the backend as an
|
||||||
|
// explicit empty value so it means "allow everyone", instead of being omitted
|
||||||
|
// and silently reusing the previously-saved list on reconnect.
|
||||||
|
it('submits an explicit empty allowed_users when the field is cleared', async () => {
|
||||||
|
vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({
|
||||||
|
status: 'connected',
|
||||||
|
restart_required: true,
|
||||||
|
});
|
||||||
|
renderWithProviders(<DiscordConfig definition={discordDef} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Your Discord bot token/), {
|
||||||
|
target: { value: 'bot-token-xyz' },
|
||||||
|
});
|
||||||
|
const allowlist = screen.getByPlaceholderText(/Discord user IDs, or \* for everyone/);
|
||||||
|
fireEvent.change(allowlist, { target: { value: '111,222' } });
|
||||||
|
fireEvent.change(allowlist, { target: { value: '' } }); // user clears it
|
||||||
|
|
||||||
|
// bot_token is the first auth mode, so its Connect button is index 0.
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: 'Connect' })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(channelConnectionsApi.connectChannel).toHaveBeenCalledWith('discord', {
|
||||||
|
authMode: 'bot_token',
|
||||||
|
credentials: { bot_token: 'bot-token-xyz', allowed_users: '' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits allowed_users entirely when the field is never touched', async () => {
|
||||||
|
vi.mocked(channelConnectionsApi.connectChannel).mockResolvedValue({
|
||||||
|
status: 'connected',
|
||||||
|
restart_required: true,
|
||||||
|
});
|
||||||
|
renderWithProviders(<DiscordConfig definition={discordDef} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByPlaceholderText(/Your Discord bot token/), {
|
||||||
|
target: { value: 'bot-token-xyz' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole('button', { name: 'Connect' })[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(channelConnectionsApi.connectChannel).toHaveBeenCalledWith('discord', {
|
||||||
|
authMode: 'bot_token',
|
||||||
|
credentials: { bot_token: 'bot-token-xyz' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('passes clearMemory when disconnecting a connected bot token account', async () => {
|
it('passes clearMemory when disconnecting a connected bot token account', async () => {
|
||||||
const store = createTestStore();
|
const store = createTestStore();
|
||||||
store.dispatch(
|
store.dispatch(
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { channelConnectionsApi } from '../../services/api/channelConnectionsApi';
|
||||||
|
import { store } from '../../store';
|
||||||
|
import { resetChannelConnectionsState } from '../../store/channelConnectionsSlice';
|
||||||
|
import type { ChannelStatusEntry } from '../../types/channels';
|
||||||
|
import { resolveStatusPatch, useChannelDefinitions } from '../useChannelDefinitions';
|
||||||
|
|
||||||
|
vi.mock('../../services/api/channelConnectionsApi', () => ({
|
||||||
|
channelConnectionsApi: {
|
||||||
|
listDefinitions: vi.fn(),
|
||||||
|
listStatus: vi.fn(),
|
||||||
|
getDefaultChannel: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockApi = vi.mocked(channelConnectionsApi);
|
||||||
|
|
||||||
|
function entry(overrides: Partial<ChannelStatusEntry>): ChannelStatusEntry {
|
||||||
|
return {
|
||||||
|
channel_id: 'discord',
|
||||||
|
auth_mode: 'bot_token',
|
||||||
|
connected: false,
|
||||||
|
has_credentials: true,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('resolveStatusPatch (issue #3712)', () => {
|
||||||
|
it('asserts connected and clears any prior error', () => {
|
||||||
|
expect(resolveStatusPatch(entry({ connected: true, error: 'stale' }), 'error')).toEqual({
|
||||||
|
status: 'connected',
|
||||||
|
lastError: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces a live listener error with its reason', () => {
|
||||||
|
expect(
|
||||||
|
resolveStatusPatch(entry({ connected: false, error: 'gateway closed (4004)' }), 'connected')
|
||||||
|
).toEqual({ status: 'error', lastError: 'gateway closed (4004)' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not stomp an in-flight connect when not-connected with no error', () => {
|
||||||
|
expect(resolveStatusPatch(entry({ connected: false }), 'connecting')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('downgrades a stale connected entry to disconnected', () => {
|
||||||
|
expect(resolveStatusPatch(entry({ connected: false }), 'connected')).toEqual({
|
||||||
|
status: 'disconnected',
|
||||||
|
lastError: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports disconnected when there is no prior status', () => {
|
||||||
|
expect(resolveStatusPatch(entry({ connected: false }), undefined)).toEqual({
|
||||||
|
status: 'disconnected',
|
||||||
|
lastError: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useChannelDefinitions loadDefinitions (issue #3794)', () => {
|
||||||
|
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||||
|
<Provider store={store}>{children}</Provider>
|
||||||
|
);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store.dispatch(resetChannelConnectionsState());
|
||||||
|
mockApi.listDefinitions.mockResolvedValue([]);
|
||||||
|
mockApi.getDefaultChannel.mockResolvedValue('discord');
|
||||||
|
mockApi.listStatus.mockResolvedValue([
|
||||||
|
{ channel_id: 'discord', auth_mode: 'bot_token', connected: true, has_credentials: true },
|
||||||
|
// Unknown channel from core must be skipped, not coerced into state (#3794).
|
||||||
|
{
|
||||||
|
channel_id: 'bogus',
|
||||||
|
auth_mode: 'bot_token',
|
||||||
|
connected: true,
|
||||||
|
has_credentials: true,
|
||||||
|
} as ChannelStatusEntry,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
store.dispatch(resetChannelConnectionsState());
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('seeds the default channel from core, syncs known channels, and skips unknown ones', async () => {
|
||||||
|
const { result } = renderHook(() => useChannelDefinitions(), { wrapper });
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
const state = store.getState().channelConnections;
|
||||||
|
// Default channel seeded from the core (source of truth).
|
||||||
|
expect(state.defaultMessagingChannel).toBe('discord');
|
||||||
|
// Known channel synced as connected.
|
||||||
|
expect(state.connections.discord?.bot_token?.status).toBe('connected');
|
||||||
|
// Unknown channel_id ignored — never added to state.
|
||||||
|
expect((state.connections as Record<string, unknown>).bogus).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,15 +3,47 @@ import { useCallback, useEffect, useState } from 'react';
|
|||||||
|
|
||||||
import { FALLBACK_DEFINITIONS } from '../lib/channels/definitions';
|
import { FALLBACK_DEFINITIONS } from '../lib/channels/definitions';
|
||||||
import { channelConnectionsApi } from '../services/api/channelConnectionsApi';
|
import { channelConnectionsApi } from '../services/api/channelConnectionsApi';
|
||||||
|
import { store } from '../store';
|
||||||
import {
|
import {
|
||||||
completeBreakingMigration,
|
completeBreakingMigration,
|
||||||
|
setDefaultMessagingChannel,
|
||||||
upsertChannelConnection,
|
upsertChannelConnection,
|
||||||
} from '../store/channelConnectionsSlice';
|
} from '../store/channelConnectionsSlice';
|
||||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||||
import type { ChannelAuthMode, ChannelDefinition, ChannelType } from '../types/channels';
|
import {
|
||||||
|
type ChannelAuthMode,
|
||||||
|
type ChannelConnectionStatus,
|
||||||
|
type ChannelDefinition,
|
||||||
|
type ChannelStatusEntry,
|
||||||
|
isChannelType,
|
||||||
|
} from '../types/channels';
|
||||||
|
|
||||||
const log = debug('channels:definitions');
|
const log = debug('channels:definitions');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a backend channel-status entry to the Redux status patch to apply
|
||||||
|
* (issue #3712). A connected entry asserts `connected`; a configured-but-failing
|
||||||
|
* entry asserts `error` and carries the live failure reason. A not-connected
|
||||||
|
* entry with no error is skipped while a connect flow is still `connecting`, so
|
||||||
|
* a stale status poll doesn't stomp an in-flight attempt. Returns `null` when
|
||||||
|
* there is nothing to assert.
|
||||||
|
*/
|
||||||
|
export function resolveStatusPatch(
|
||||||
|
entry: ChannelStatusEntry,
|
||||||
|
currentStatus: ChannelConnectionStatus | undefined
|
||||||
|
): { status: ChannelConnectionStatus; lastError: string | undefined } | null {
|
||||||
|
if (entry.connected) {
|
||||||
|
return { status: 'connected', lastError: undefined };
|
||||||
|
}
|
||||||
|
if (entry.error) {
|
||||||
|
return { status: 'error', lastError: entry.error };
|
||||||
|
}
|
||||||
|
if (currentStatus === 'connecting') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { status: 'disconnected', lastError: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
export function useChannelDefinitions() {
|
export function useChannelDefinitions() {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const channelConnections = useAppSelector(state => state.channelConnections);
|
const channelConnections = useAppSelector(state => state.channelConnections);
|
||||||
@@ -33,30 +65,54 @@ export function useChannelDefinitions() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [defs, statusEntries] = await Promise.all([
|
const [defs, statusEntries, defaultChannel] = await Promise.all([
|
||||||
channelConnectionsApi.listDefinitions().catch(() => null),
|
channelConnectionsApi.listDefinitions().catch(() => null),
|
||||||
channelConnectionsApi.listStatus().catch(() => null),
|
channelConnectionsApi.listStatus().catch(() => null),
|
||||||
|
channelConnectionsApi.getDefaultChannel().catch(() => null),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
|
||||||
|
// Seed the default messaging channel from the core (source of truth for
|
||||||
|
// proactive routing) so the UI reflects the persisted selection across
|
||||||
|
// reloads, not just redux-persist's local copy (issue #3712).
|
||||||
|
if (defaultChannel) {
|
||||||
|
dispatch(setDefaultMessagingChannel(defaultChannel));
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedDefs =
|
const resolvedDefs =
|
||||||
defs && Array.isArray(defs) && defs.length > 0 ? defs : FALLBACK_DEFINITIONS;
|
defs && Array.isArray(defs) && defs.length > 0 ? defs : FALLBACK_DEFINITIONS;
|
||||||
setDefinitions(resolvedDefs);
|
setDefinitions(resolvedDefs);
|
||||||
log('loaded %d channel definitions', resolvedDefs.length);
|
log('loaded %d channel definitions', resolvedDefs.length);
|
||||||
|
|
||||||
if (statusEntries && Array.isArray(statusEntries)) {
|
if (statusEntries && Array.isArray(statusEntries)) {
|
||||||
|
// Read live store state (not the closed-over selector value) so the
|
||||||
|
// connecting-guard in `resolveStatusPatch` sees the current status.
|
||||||
|
const liveConnections = store.getState().channelConnections.connections;
|
||||||
for (const entry of statusEntries) {
|
for (const entry of statusEntries) {
|
||||||
const channel = entry.channel_id as ChannelType;
|
// Skip unknown channels from core rather than coercing them into
|
||||||
const authMode = entry.auth_mode as ChannelAuthMode;
|
// state as if valid (#3794 review).
|
||||||
if (entry.connected) {
|
if (!isChannelType(entry.channel_id)) {
|
||||||
dispatch(
|
log('ignoring unknown channel_id from status sync: %s', entry.channel_id);
|
||||||
upsertChannelConnection({
|
continue;
|
||||||
channel,
|
|
||||||
authMode,
|
|
||||||
patch: { status: 'connected', capabilities: ['read', 'write'] },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
const channel = entry.channel_id;
|
||||||
|
const authMode = entry.auth_mode as ChannelAuthMode;
|
||||||
|
const currentStatus = liveConnections[channel]?.[authMode]?.status;
|
||||||
|
const patch = resolveStatusPatch(entry, currentStatus);
|
||||||
|
if (!patch) continue;
|
||||||
|
dispatch(
|
||||||
|
upsertChannelConnection({
|
||||||
|
channel,
|
||||||
|
authMode,
|
||||||
|
patch: {
|
||||||
|
status: patch.status,
|
||||||
|
lastError: patch.lastError,
|
||||||
|
// Only assert capabilities when actually connected; leave them
|
||||||
|
// untouched otherwise so a disconnect doesn't wipe them.
|
||||||
|
...(entry.connected ? { capabilities: ['read', 'write'] } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
log('synced %d status entries', statusEntries.length);
|
log('synced %d status entries', statusEntries.length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ export const FALLBACK_DEFINITIONS: ChannelDefinition[] = [
|
|||||||
required: true,
|
required: true,
|
||||||
placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'chat_id',
|
||||||
|
label: 'Chat ID',
|
||||||
|
field_type: 'string',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'Optional: default chat for outbound messages',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'allowed_users',
|
key: 'allowed_users',
|
||||||
label: 'Allowed Users',
|
label: 'Allowed Users',
|
||||||
@@ -85,6 +92,20 @@ export const FALLBACK_DEFINITIONS: ChannelDefinition[] = [
|
|||||||
required: false,
|
required: false,
|
||||||
placeholder: 'Optional: restrict to a specific server',
|
placeholder: 'Optional: restrict to a specific server',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'channel_id',
|
||||||
|
label: 'Channel ID',
|
||||||
|
field_type: 'string',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'Optional: default channel for outbound messages',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'allowed_users',
|
||||||
|
label: 'Allowed Users',
|
||||||
|
field_type: 'string',
|
||||||
|
required: false,
|
||||||
|
placeholder: 'Comma-separated Discord user IDs, or * for everyone (blank = everyone)',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
auth_action: undefined,
|
auth_action: undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,3 +38,33 @@ describe('channelConnectionsApi.disconnectChannel', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('channelConnectionsApi default channel (issue #3712)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockCallCoreRpc.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updatePreferences persists the default via channels_set_default', async () => {
|
||||||
|
mockCallCoreRpc.mockResolvedValue({ active_channel: 'discord', restart_required: false });
|
||||||
|
await channelConnectionsApi.updatePreferences('discord');
|
||||||
|
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||||
|
method: 'openhuman.channels_set_default',
|
||||||
|
params: { channel: 'discord' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDefaultChannel returns the core active_channel', async () => {
|
||||||
|
mockCallCoreRpc.mockResolvedValue({ active_channel: 'telegram' });
|
||||||
|
const result = await channelConnectionsApi.getDefaultChannel();
|
||||||
|
expect(mockCallCoreRpc).toHaveBeenCalledWith({
|
||||||
|
method: 'openhuman.channels_get_default',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
expect(result).toBe('telegram');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getDefaultChannel returns null when active_channel is absent', async () => {
|
||||||
|
mockCallCoreRpc.mockResolvedValue({});
|
||||||
|
expect(await channelConnectionsApi.getDefaultChannel()).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import type {
|
import {
|
||||||
BotPermissionCheck,
|
type BotPermissionCheck,
|
||||||
ChannelAuthMode,
|
type ChannelAuthMode,
|
||||||
ChannelConnectionResult,
|
type ChannelConnectionResult,
|
||||||
ChannelDefinition,
|
type ChannelDefinition,
|
||||||
ChannelStatusEntry,
|
type ChannelStatusEntry,
|
||||||
ChannelType,
|
type ChannelType,
|
||||||
DiscordGuild,
|
type DiscordGuild,
|
||||||
DiscordTextChannel,
|
type DiscordTextChannel,
|
||||||
|
isChannelType,
|
||||||
} from '../../types/channels';
|
} from '../../types/channels';
|
||||||
import { callCoreRpc } from '../coreRpcClient';
|
import { callCoreRpc } from '../coreRpcClient';
|
||||||
|
|
||||||
@@ -244,8 +245,27 @@ export const channelConnectionsApi = {
|
|||||||
return normalizePermissionCheck(result);
|
return normalizePermissionCheck(result);
|
||||||
},
|
},
|
||||||
|
|
||||||
/** Placeholder for default channel preference sync. */
|
/**
|
||||||
|
* Persist the default messaging channel to the core (issue #3712). The core
|
||||||
|
* stores it in `channels_config.active_channel` and applies it live, so the
|
||||||
|
* agent's proactive delivery follows the selection without a restart.
|
||||||
|
*/
|
||||||
updatePreferences: async (defaultMessagingChannel: ChannelType): Promise<void> => {
|
updatePreferences: async (defaultMessagingChannel: ChannelType): Promise<void> => {
|
||||||
void defaultMessagingChannel;
|
await callCoreRpc({
|
||||||
|
method: 'openhuman.channels_set_default',
|
||||||
|
params: { channel: defaultMessagingChannel },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Read the core's persisted default messaging channel. */
|
||||||
|
getDefaultChannel: async (): Promise<ChannelType | null> => {
|
||||||
|
const result = await callCoreRpc<unknown>({
|
||||||
|
method: 'openhuman.channels_get_default',
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
const record = expectObject<{ active_channel?: unknown }>(result, 'Channel get_default');
|
||||||
|
// Validate against known slugs so an unexpected core value can't leak into
|
||||||
|
// Redux/API consumers despite the `ChannelType | null` contract (#3794 review).
|
||||||
|
return isChannelType(record.active_channel) ? record.active_channel : null;
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
export type ChannelType = 'telegram' | 'discord' | 'web' | 'lark' | 'dingtalk' | 'mcp' | 'yuanbao';
|
export type ChannelType = 'telegram' | 'discord' | 'web' | 'lark' | 'dingtalk' | 'mcp' | 'yuanbao';
|
||||||
|
|
||||||
|
/** Every valid {@link ChannelType}, for runtime validation of values that arrive
|
||||||
|
* from the core (which is typed `string`). `satisfies` keeps this list in
|
||||||
|
* lockstep with the `ChannelType` union — adding a member there without updating
|
||||||
|
* here is a compile error. */
|
||||||
|
export const KNOWN_CHANNEL_TYPES = [
|
||||||
|
'telegram',
|
||||||
|
'discord',
|
||||||
|
'web',
|
||||||
|
'lark',
|
||||||
|
'dingtalk',
|
||||||
|
'mcp',
|
||||||
|
'yuanbao',
|
||||||
|
] as const satisfies readonly ChannelType[];
|
||||||
|
|
||||||
|
/** Runtime guard: narrow an untrusted value to a known `ChannelType`. Use before
|
||||||
|
* coercing core-provided `channel_id` / `active_channel` strings so unknown
|
||||||
|
* channels never leak into Redux/API consumers (issue #3794 review). */
|
||||||
|
export function isChannelType(value: unknown): value is ChannelType {
|
||||||
|
return typeof value === 'string' && (KNOWN_CHANNEL_TYPES as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
export type ChannelAuthMode = 'managed_dm' | 'oauth' | 'bot_token' | 'api_key';
|
export type ChannelAuthMode = 'managed_dm' | 'oauth' | 'bot_token' | 'api_key';
|
||||||
|
|
||||||
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
export type ChannelConnectionStatus = 'connected' | 'connecting' | 'disconnected' | 'error';
|
||||||
@@ -74,6 +95,10 @@ export interface ChannelStatusEntry {
|
|||||||
auth_mode: ChannelAuthMode;
|
auth_mode: ChannelAuthMode;
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
has_credentials: boolean;
|
has_credentials: boolean;
|
||||||
|
/** Live failure reason from the supervised listener when the channel is
|
||||||
|
* configured but its runtime listener is currently failing (issue #3712).
|
||||||
|
* Absent when healthy, still starting, or for listener-less modes. */
|
||||||
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChannelConnectionResult {
|
export interface ChannelConnectionResult {
|
||||||
|
|||||||
@@ -287,6 +287,8 @@ test.describe('Harness - Composio tool-call prompt flow', () => {
|
|||||||
|
|
||||||
await sendMessage(page, 'create a linear issue titled Fix authentication timeout');
|
await sendMessage(page, 'create a linear issue titled Fix authentication timeout');
|
||||||
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
|
await expect(page.getByText(CANARY).first()).toBeVisible({ timeout: 60_000 });
|
||||||
await expect(page.getByText(/I have created the Linear issue/i)).toBeVisible();
|
// `.first()` — the confirmation text also appears in the tool-result echo
|
||||||
|
// pane, so an unscoped match trips Playwright strict mode (2 elements).
|
||||||
|
await expect(page.getByText(/I have created the Linear issue/i).first()).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+11
-11
@@ -272,9 +272,8 @@ struct LoginTokenConsumeEnvelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
|
||||||
struct LoginTokenConsumeData {
|
struct LoginTokenConsumeData {
|
||||||
jwt_token: String,
|
jwt: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decrypted OAuth token payload for handing off tokens to a local service or skill.
|
/// Decrypted OAuth token payload for handing off tokens to a local service or skill.
|
||||||
@@ -423,17 +422,21 @@ impl BackendOAuthClient {
|
|||||||
let token = login_token.trim();
|
let token = login_token.trim();
|
||||||
anyhow::ensure!(!token.is_empty(), "login token is required");
|
anyhow::ensure!(!token.is_empty(), "login token is required");
|
||||||
|
|
||||||
|
// Backend serves `POST /auth/login-token/consume` with the token in a JSON
|
||||||
|
// body `{ token, audience? }` and returns `{ success, data: { jwt } }`
|
||||||
|
// (see backend `routes/auth.ts`). The legacy
|
||||||
|
// `telegram/login-tokens/{token}/consume` path-param route was removed, so
|
||||||
|
// the old call 404'd and Telegram/OAuth-token login could never complete
|
||||||
|
// (WIRING_GAPS_AUDIT C1/C2).
|
||||||
let url = self
|
let url = self
|
||||||
.base
|
.base
|
||||||
.join(&format!(
|
.join("auth/login-token/consume")
|
||||||
"telegram/login-tokens/{}/consume",
|
|
||||||
urlencoding::encode(token)
|
|
||||||
))
|
|
||||||
.context("build login-token consume URL")?;
|
.context("build login-token consume URL")?;
|
||||||
|
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
.post(url)
|
.post(url)
|
||||||
|
.json(&serde_json::json!({ "token": token }))
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.context("consume login token")?;
|
.context("consume login token")?;
|
||||||
@@ -450,11 +453,8 @@ impl BackendOAuthClient {
|
|||||||
anyhow::bail!("consume login token unsuccessful: {text}");
|
anyhow::bail!("consume login token unsuccessful: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
let jwt = env.data.jwt_token.trim().to_string();
|
let jwt = env.data.jwt.trim().to_string();
|
||||||
anyhow::ensure!(
|
anyhow::ensure!(!jwt.is_empty(), "consume login token response missing jwt");
|
||||||
!jwt.is_empty(),
|
|
||||||
"consume login token response missing jwtToken"
|
|
||||||
);
|
|
||||||
Ok(jwt)
|
Ok(jwt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ async fn spawn_header_capture_server() -> (String, CapturedHeaders) {
|
|||||||
captured.push(&headers);
|
captured.push(&headers);
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": { "jwtToken": "mock-jwt-token" }
|
"data": { "jwt": "mock-jwt-token" }
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,10 +184,7 @@ async fn spawn_header_capture_server() -> (String, CapturedHeaders) {
|
|||||||
|
|
||||||
let captured = CapturedHeaders::default();
|
let captured = CapturedHeaders::default();
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route(
|
.route("/auth/login-token/consume", post(capture_consume))
|
||||||
"/telegram/login-tokens/{token}/consume",
|
|
||||||
post(capture_consume),
|
|
||||||
)
|
|
||||||
.route("/probe", get(capture_probe))
|
.route("/probe", get(capture_probe))
|
||||||
.with_state(captured.clone());
|
.with_state(captured.clone());
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
|||||||
+123
-1
@@ -574,6 +574,7 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
|||||||
let io_memory_sync = io.clone();
|
let io_memory_sync = io.clone();
|
||||||
let io_agent_meetings = io.clone();
|
let io_agent_meetings = io.clone();
|
||||||
let io_tinyplace = io.clone();
|
let io_tinyplace = io.clone();
|
||||||
|
let io_channel_status = io.clone();
|
||||||
|
|
||||||
// 2. Dictation hotkey events → broadcast to all connected clients.
|
// 2. Dictation hotkey events → broadcast to all connected clients.
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
@@ -1143,6 +1144,104 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
|
|||||||
}
|
}
|
||||||
log::debug!("[socketio] tinyplace stream bridge stopped");
|
log::debug!("[socketio] tinyplace stream bridge stopped");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 11. Channel listener health → broadcast `channel:connection-updated` to
|
||||||
|
// all clients so the Messaging tab reflects the *live* connection state
|
||||||
|
// instead of a stale, credential-presence-only "Connected" (issue
|
||||||
|
// #3712). The supervised listener publishes `ChannelConnected` when it
|
||||||
|
// (re)enters its recv loop and `ChannelDisconnected { reason }` when it
|
||||||
|
// errors/exits. Only listener-backed channels (telegram/discord
|
||||||
|
// `bot_token`) fire these, so we map them to the `bot_token` auth mode —
|
||||||
|
// the frontend `normalizeChannelConnectionUpdatePayload` drops any
|
||||||
|
// channel/mode it doesn't recognise.
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let bus = {
|
||||||
|
const RETRY_INTERVAL_MS: u64 = 250;
|
||||||
|
const MAX_WAIT_SECS: u64 = 30;
|
||||||
|
let max_attempts = (MAX_WAIT_SECS * 1000) / RETRY_INTERVAL_MS;
|
||||||
|
let mut attempts: u64 = 0;
|
||||||
|
loop {
|
||||||
|
if let Some(bus) = crate::core::event_bus::global() {
|
||||||
|
break bus;
|
||||||
|
}
|
||||||
|
attempts += 1;
|
||||||
|
if attempts > max_attempts {
|
||||||
|
log::warn!(
|
||||||
|
"[socketio] event_bus not initialised after {}s — channel_status bridge giving up",
|
||||||
|
MAX_WAIT_SECS
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(RETRY_INTERVAL_MS)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut rx = bus.raw_receiver();
|
||||||
|
loop {
|
||||||
|
let event = match rx.recv().await {
|
||||||
|
Ok(event) => event,
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
||||||
|
log::warn!(
|
||||||
|
"[socketio] dropped {} event_bus events due to lag (channel_status bridge)",
|
||||||
|
skipped
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
|
||||||
|
};
|
||||||
|
let payload = match event {
|
||||||
|
crate::core::event_bus::DomainEvent::ChannelConnected { channel } => {
|
||||||
|
log::debug!(
|
||||||
|
"[socketio] broadcast channel:connection-updated {channel} -> connected"
|
||||||
|
);
|
||||||
|
Some(channel_connection_update_payload(
|
||||||
|
&channel,
|
||||||
|
"connected",
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
crate::core::event_bus::DomainEvent::ChannelDisconnected { channel, reason } => {
|
||||||
|
log::debug!(
|
||||||
|
"[socketio] broadcast channel:connection-updated {channel} -> error reason_len={}",
|
||||||
|
reason.len()
|
||||||
|
);
|
||||||
|
Some(channel_connection_update_payload(
|
||||||
|
&channel,
|
||||||
|
"error",
|
||||||
|
Some(&reason),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(payload) = payload {
|
||||||
|
// Emit both colon and underscore variants for FE compatibility.
|
||||||
|
let _ = io_channel_status.emit("channel:connection-updated", &payload);
|
||||||
|
let _ = io_channel_status.emit("channel_connection_updated", &payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::debug!("[socketio] channel_status bridge stopped");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the `channel:connection-updated` payload broadcast when a supervised
|
||||||
|
/// channel listener changes state (issue #3712). Listener-backed channels are
|
||||||
|
/// always the `bot_token` auth mode (the only mode that materialises a runtime
|
||||||
|
/// listener for telegram/discord); `last_error` carries the disconnect reason.
|
||||||
|
/// Matches the shape consumed by the frontend
|
||||||
|
/// `normalizeChannelConnectionUpdatePayload`.
|
||||||
|
pub(crate) fn channel_connection_update_payload(
|
||||||
|
channel: &str,
|
||||||
|
status: &str,
|
||||||
|
last_error: Option<&str>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut payload = serde_json::json!({
|
||||||
|
"channel": channel,
|
||||||
|
"auth_mode": "bot_token",
|
||||||
|
"status": status,
|
||||||
|
});
|
||||||
|
if let Some(reason) = last_error {
|
||||||
|
payload["last_error"] = serde_json::Value::String(reason.to_string());
|
||||||
|
}
|
||||||
|
payload
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Join `socket` to `room`, logging the result.
|
/// Join `socket` to `room`, logging the result.
|
||||||
@@ -1252,7 +1351,30 @@ fn emit_with_aliases(socket: &SocketRef, name: &str, payload: &serde_json::Value
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{event_alias, origin_is_allowed};
|
use super::{channel_connection_update_payload, event_alias, origin_is_allowed};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_connection_update_payload_connected_omits_error() {
|
||||||
|
let payload = channel_connection_update_payload("discord", "connected", None);
|
||||||
|
assert_eq!(payload["channel"], "discord");
|
||||||
|
// Listener-backed channels always map to the bot_token auth mode.
|
||||||
|
assert_eq!(payload["auth_mode"], "bot_token");
|
||||||
|
assert_eq!(payload["status"], "connected");
|
||||||
|
assert!(
|
||||||
|
payload.get("last_error").is_none(),
|
||||||
|
"connected payload must not carry a last_error: {payload}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn channel_connection_update_payload_error_carries_reason() {
|
||||||
|
let payload =
|
||||||
|
channel_connection_update_payload("discord", "error", Some("gateway closed (4004)"));
|
||||||
|
assert_eq!(payload["channel"], "discord");
|
||||||
|
assert_eq!(payload["auth_mode"], "bot_token");
|
||||||
|
assert_eq!(payload["status"], "error");
|
||||||
|
assert_eq!(payload["last_error"], "gateway closed (4004)");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_alias_translates_between_delimiters() {
|
fn event_alias_translates_between_delimiters() {
|
||||||
|
|||||||
@@ -328,6 +328,7 @@ mod tests {
|
|||||||
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
|
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
|
||||||
config.channels_config.telegram = Some(TelegramConfig {
|
config.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token: "fake:token".into(),
|
bot_token: "fake:token".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec!["user1".into()],
|
allowed_users: vec!["user1".into()],
|
||||||
stream_mode: StreamMode::default(),
|
stream_mode: StreamMode::default(),
|
||||||
draft_update_interval_ms: 2000,
|
draft_update_interval_ms: 2000,
|
||||||
@@ -385,6 +386,7 @@ mod tests {
|
|||||||
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
|
config.channels_config = crate::openhuman::config::ChannelsConfig::default();
|
||||||
config.channels_config.telegram = Some(TelegramConfig {
|
config.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token: "fake".into(),
|
bot_token: "fake".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec![],
|
allowed_users: vec![],
|
||||||
stream_mode: StreamMode::default(),
|
stream_mode: StreamMode::default(),
|
||||||
draft_update_interval_ms: 2000,
|
draft_update_interval_ms: 2000,
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ fn telegram_definition() -> ChannelDefinition {
|
|||||||
required: true,
|
required: true,
|
||||||
placeholder: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
placeholder: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11",
|
||||||
},
|
},
|
||||||
|
FieldRequirement {
|
||||||
|
key: "chat_id",
|
||||||
|
label: "Chat ID",
|
||||||
|
field_type: "string",
|
||||||
|
required: false,
|
||||||
|
placeholder: "Optional: default chat for outbound messages",
|
||||||
|
},
|
||||||
FieldRequirement {
|
FieldRequirement {
|
||||||
key: "allowed_users",
|
key: "allowed_users",
|
||||||
label: "Allowed Users",
|
label: "Allowed Users",
|
||||||
@@ -247,6 +254,14 @@ fn discord_definition() -> ChannelDefinition {
|
|||||||
required: false,
|
required: false,
|
||||||
placeholder: "Optional: default channel for outbound messages",
|
placeholder: "Optional: default channel for outbound messages",
|
||||||
},
|
},
|
||||||
|
FieldRequirement {
|
||||||
|
key: "allowed_users",
|
||||||
|
label: "Allowed Users",
|
||||||
|
field_type: "string",
|
||||||
|
required: false,
|
||||||
|
placeholder:
|
||||||
|
"Comma-separated Discord user IDs, or * for everyone (blank = everyone)",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
auth_action: None,
|
auth_action: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ fn discord_has_bot_token_and_oauth() {
|
|||||||
assert_eq!(managed.unwrap().auth_action, Some("discord_managed_link"));
|
assert_eq!(managed.unwrap().auth_action, Some("discord_managed_link"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn discord_bot_token_exposes_allowed_users_field() {
|
||||||
|
// Issue #3763: the Discord bot_token connect UI must offer an allowed_users
|
||||||
|
// field (like Telegram) so a self-hosted bot's allowlist is set in-app
|
||||||
|
// instead of by hand-editing config.toml.
|
||||||
|
let def = find_channel_definition("discord").expect("discord not found");
|
||||||
|
let bot_token = def
|
||||||
|
.auth_mode_spec(ChannelAuthMode::BotToken)
|
||||||
|
.expect("discord bot_token mode");
|
||||||
|
let allowed = bot_token
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.find(|f| f.key == "allowed_users")
|
||||||
|
.expect("discord bot_token must expose an allowed_users field");
|
||||||
|
assert!(
|
||||||
|
!allowed.required,
|
||||||
|
"allowed_users must be optional (blank = allow everyone)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn find_unknown_channel_returns_none() {
|
fn find_unknown_channel_returns_none() {
|
||||||
assert!(find_channel_definition("nonexistent").is_none());
|
assert!(find_channel_definition("nonexistent").is_none());
|
||||||
|
|||||||
@@ -22,6 +22,34 @@ pub(crate) fn credential_provider(channel_id: &str, mode: ChannelAuthMode) -> St
|
|||||||
format!("channel:{}:{}", channel_id, mode)
|
format!("channel:{}:{}", channel_id, mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge a channel's live supervised-listener health into its credential/config
|
||||||
|
/// derived `connected` flag (issue #3712).
|
||||||
|
///
|
||||||
|
/// Only listener-backed modes (those that materialise a TOML config block —
|
||||||
|
/// `has_config`) have a `channel:<id>` health component, kept current by the
|
||||||
|
/// supervisor's `ChannelConnected`/`ChannelDisconnected` events. For those, a
|
||||||
|
/// live `error` overrides the optimistic presence-based `connected` and carries
|
||||||
|
/// the failure reason to the UI; an `ok` confirms it. While the listener is
|
||||||
|
/// still `starting` (or has no component yet) we keep the presence-based value
|
||||||
|
/// so a freshly-configured channel isn't reported as broken before its first
|
||||||
|
/// connect attempt. Modes without a runtime listener (e.g. managed-DM) are left
|
||||||
|
/// untouched. Returns `(connected, error)`.
|
||||||
|
pub(crate) fn merge_listener_health(
|
||||||
|
presence_connected: bool,
|
||||||
|
has_config: bool,
|
||||||
|
health_status: Option<&str>,
|
||||||
|
health_last_error: Option<&str>,
|
||||||
|
) -> (bool, Option<String>) {
|
||||||
|
if !has_config {
|
||||||
|
return (presence_connected, None);
|
||||||
|
}
|
||||||
|
match health_status {
|
||||||
|
Some("error") => (false, health_last_error.map(str::to_string)),
|
||||||
|
Some("ok") => (true, None),
|
||||||
|
_ => (presence_connected, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn channel_config_connected(
|
pub(crate) fn channel_config_connected(
|
||||||
config: &Config,
|
config: &Config,
|
||||||
channel_id: &str,
|
channel_id: &str,
|
||||||
@@ -255,6 +283,16 @@ pub async fn connect_channel(
|
|||||||
.to_string();
|
.to_string();
|
||||||
let allowed_users = parse_allowed_users(creds_map.get("allowed_users"));
|
let allowed_users = parse_allowed_users(creds_map.get("allowed_users"));
|
||||||
let allowed_users_count = allowed_users.len();
|
let allowed_users_count = allowed_users.len();
|
||||||
|
// Default chat for recipient-less proactive sends (mirrors Discord's
|
||||||
|
// `channel_id`). Read fresh from the form each connect: present ⇒ use it
|
||||||
|
// (empty ⇒ cleared); absent ⇒ unset.
|
||||||
|
let chat_id = creds_map
|
||||||
|
.get("chat_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string);
|
||||||
|
let has_chat_id = chat_id.is_some();
|
||||||
|
|
||||||
let mut persisted = config.clone();
|
let mut persisted = config.clone();
|
||||||
let (stream_mode, draft_update_interval_ms, silent_streaming, mention_only) =
|
let (stream_mode, draft_update_interval_ms, silent_streaming, mention_only) =
|
||||||
@@ -276,6 +314,7 @@ pub async fn connect_channel(
|
|||||||
|
|
||||||
persisted.channels_config.telegram = Some(TelegramConfig {
|
persisted.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token,
|
bot_token,
|
||||||
|
chat_id,
|
||||||
allowed_users,
|
allowed_users,
|
||||||
stream_mode,
|
stream_mode,
|
||||||
draft_update_interval_ms,
|
draft_update_interval_ms,
|
||||||
@@ -291,6 +330,7 @@ pub async fn connect_channel(
|
|||||||
tracing::info!(
|
tracing::info!(
|
||||||
target: "openhuman::channels",
|
target: "openhuman::channels",
|
||||||
allowed_users_count,
|
allowed_users_count,
|
||||||
|
has_chat_id,
|
||||||
mention_only,
|
mention_only,
|
||||||
"[telegram] connect_channel: wrote channels_config.telegram; restart core for listener to load token"
|
"[telegram] connect_channel: wrote channels_config.telegram; restart core for listener to load token"
|
||||||
);
|
);
|
||||||
@@ -318,13 +358,21 @@ pub async fn connect_channel(
|
|||||||
|
|
||||||
let mut persisted = config.clone();
|
let mut persisted = config.clone();
|
||||||
let existing = persisted.channels_config.discord.as_ref();
|
let existing = persisted.channels_config.discord.as_ref();
|
||||||
let parsed_allowed_users = parse_allowed_users(creds_map.get("allowed_users"));
|
// Distinguish an *explicitly cleared* allowlist from an *omitted* one.
|
||||||
let allowed_users = if parsed_allowed_users.is_empty() {
|
// The field is advertised as "blank = everyone" (definitions.rs) and the
|
||||||
existing
|
// provider treats an empty list as allow-all, but the old logic reused
|
||||||
|
// the saved list whenever the parsed value was empty — so a user who
|
||||||
|
// cleared the allowlist on reconnect stayed restricted to the previous
|
||||||
|
// users (#3794 review — Codex P2). The key is present in `creds_map`
|
||||||
|
// (even as an empty string) only when the FE sends it; a cleared field
|
||||||
|
// now submits an explicit empty value. So: present ⇒ honor literally
|
||||||
|
// (empty ⇒ allow-all); absent ⇒ reuse the saved list (reconnect
|
||||||
|
// convenience for callers that don't resend the field at all).
|
||||||
|
let allowed_users = match creds_map.get("allowed_users") {
|
||||||
|
Some(raw) => parse_allowed_users(Some(raw)),
|
||||||
|
None => existing
|
||||||
.map(|cfg| cfg.allowed_users.clone())
|
.map(|cfg| cfg.allowed_users.clone())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default(),
|
||||||
} else {
|
|
||||||
parsed_allowed_users
|
|
||||||
};
|
};
|
||||||
let allowed_users_count = allowed_users.len();
|
let allowed_users_count = allowed_users.len();
|
||||||
let listen_to_bots = parse_optional_bool(creds_map.get("listen_to_bots"))
|
let listen_to_bots = parse_optional_bool(creds_map.get("listen_to_bots"))
|
||||||
@@ -505,13 +553,25 @@ pub async fn channel_status(
|
|||||||
None => all_channel_definitions(),
|
None => all_channel_definitions(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Snapshot live listener health once so every entry reflects the same
|
||||||
|
// moment. The supervisor keeps `channel:<id>` components current via
|
||||||
|
// `ChannelConnected`/`ChannelDisconnected` (issue #3712).
|
||||||
|
let health = crate::openhuman::health::snapshot();
|
||||||
|
|
||||||
let mut entries = Vec::new();
|
let mut entries = Vec::new();
|
||||||
for def in &defs {
|
for def in &defs {
|
||||||
|
let comp = health.components.get(&format!("channel:{}", def.id));
|
||||||
for spec in &def.auth_modes {
|
for spec in &def.auth_modes {
|
||||||
let provider_key = credential_provider(def.id, spec.mode);
|
let provider_key = credential_provider(def.id, spec.mode);
|
||||||
let has_creds = stored_providers.iter().any(|p| p == &provider_key);
|
let has_creds = stored_providers.iter().any(|p| p == &provider_key);
|
||||||
let has_config = channel_config_connected(config, def.id, spec.mode);
|
let has_config = channel_config_connected(config, def.id, spec.mode);
|
||||||
let connected = has_creds || has_config;
|
let presence_connected = has_creds || has_config;
|
||||||
|
let (connected, error) = merge_listener_health(
|
||||||
|
presence_connected,
|
||||||
|
has_config,
|
||||||
|
comp.map(|c| c.status.as_str()),
|
||||||
|
comp.and_then(|c| c.last_error.as_deref()),
|
||||||
|
);
|
||||||
entries.push(ChannelStatusEntry {
|
entries.push(ChannelStatusEntry {
|
||||||
channel_id: def.id.to_string(),
|
channel_id: def.id.to_string(),
|
||||||
auth_mode: spec.mode,
|
auth_mode: spec.mode,
|
||||||
@@ -521,6 +581,7 @@ pub async fn channel_status(
|
|||||||
// credentials. Collapsing these misleads callers that branch on
|
// credentials. Collapsing these misleads callers that branch on
|
||||||
// credential presence (e.g. "needs re-auth" surfaces).
|
// credential presence (e.g. "needs re-auth" surfaces).
|
||||||
has_credentials: has_creds,
|
has_credentials: has_creds,
|
||||||
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -528,6 +589,50 @@ pub async fn channel_status(
|
|||||||
Ok(RpcOutcome::new(entries, vec![]))
|
Ok(RpcOutcome::new(entries, vec![]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the default messaging channel for proactive agent delivery (issue #3712
|
||||||
|
/// — "switch default channel Telegram↔Discord"). Persists
|
||||||
|
/// `channels_config.active_channel` and applies a runtime override
|
||||||
|
/// ([`crate::openhuman::channels::proactive::set_runtime_active_channel`]) so the
|
||||||
|
/// change takes effect immediately, without restarting the channel runtime.
|
||||||
|
pub async fn set_default_channel(
|
||||||
|
config: &mut Config,
|
||||||
|
channel: &str,
|
||||||
|
) -> Result<RpcOutcome<Value>, String> {
|
||||||
|
let canonical = channel.trim().to_ascii_lowercase();
|
||||||
|
if canonical.is_empty() {
|
||||||
|
return Err("channel must not be empty".to_string());
|
||||||
|
}
|
||||||
|
// Accept any known channel definition, plus the in-app "web" channel.
|
||||||
|
if canonical != "web" && find_channel_definition(&canonical).is_none() {
|
||||||
|
return Err(format!("unknown channel: {channel}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
config.channels_config.active_channel = Some(canonical.clone());
|
||||||
|
config
|
||||||
|
.save()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("failed to persist default channel: {e}"))?;
|
||||||
|
|
||||||
|
// Apply live so proactive routing follows the new default immediately.
|
||||||
|
crate::openhuman::channels::proactive::set_runtime_active_channel(Some(canonical.clone()));
|
||||||
|
|
||||||
|
Ok(RpcOutcome::single_log(
|
||||||
|
json!({ "active_channel": canonical, "restart_required": false }),
|
||||||
|
format!("default messaging channel set to {canonical}"),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the persisted default messaging channel
|
||||||
|
/// (`channels_config.active_channel`), defaulting to `"web"` when unset.
|
||||||
|
pub fn get_default_channel(config: &Config) -> Result<RpcOutcome<Value>, String> {
|
||||||
|
let active = config
|
||||||
|
.channels_config
|
||||||
|
.active_channel
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "web".to_string());
|
||||||
|
Ok(RpcOutcome::new(json!({ "active_channel": active }), vec![]))
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the slugs of all messaging channels currently connected,
|
/// Return the slugs of all messaging channels currently connected,
|
||||||
/// merging the two storage layers OpenHuman uses for connection state.
|
/// merging the two storage layers OpenHuman uses for connection state.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -23,12 +23,14 @@ pub(crate) use connect::channel_config_connected;
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) use connect::credential_provider;
|
pub(crate) use connect::credential_provider;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
pub(crate) use connect::merge_listener_health;
|
||||||
|
#[cfg(test)]
|
||||||
pub(crate) use connect::parse_allowed_users;
|
pub(crate) use connect::parse_allowed_users;
|
||||||
|
|
||||||
// Re-export public ops functions.
|
// Re-export public ops functions.
|
||||||
pub use connect::{
|
pub use connect::{
|
||||||
channel_status, connect_channel, connected_channel_slugs, describe_channel, disconnect_channel,
|
channel_status, connect_channel, connected_channel_slugs, describe_channel, disconnect_channel,
|
||||||
list_channels, test_channel,
|
get_default_channel, list_channels, set_default_channel, test_channel,
|
||||||
};
|
};
|
||||||
pub use discord::{
|
pub use discord::{
|
||||||
discord_check_permissions, discord_link_check, discord_link_start, discord_list_channels,
|
discord_check_permissions, discord_link_check, discord_link_start, discord_list_channels,
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ pub struct ChannelStatusEntry {
|
|||||||
pub auth_mode: super::super::definitions::ChannelAuthMode,
|
pub auth_mode: super::super::definitions::ChannelAuthMode,
|
||||||
pub connected: bool,
|
pub connected: bool,
|
||||||
pub has_credentials: bool,
|
pub has_credentials: bool,
|
||||||
|
/// Live failure reason from the supervised listener when the channel is
|
||||||
|
/// configured but its runtime listener is currently in an error state
|
||||||
|
/// (issue #3712 — surface a real error instead of a false "Connected").
|
||||||
|
/// `None` when healthy, still starting, or when the mode has no runtime
|
||||||
|
/// listener (e.g. managed-DM, which routes through the backend bot).
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result returned by `test_channel`.
|
/// Result returned by `test_channel`.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ fn channel_config_connected_covers_config_backed_modes() {
|
|||||||
|
|
||||||
config.channels_config.telegram = Some(TelegramConfig {
|
config.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token: "telegram-token".into(),
|
bot_token: "telegram-token".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec![],
|
allowed_users: vec![],
|
||||||
stream_mode: Default::default(),
|
stream_mode: Default::default(),
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
@@ -307,6 +308,120 @@ async fn connect_discord_bot_token_persists_runtime_config() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_telegram_bot_token_persists_chat_id() {
|
||||||
|
let (_tmp, config) = isolated_test_config();
|
||||||
|
let result = connect_channel(
|
||||||
|
&config,
|
||||||
|
"telegram",
|
||||||
|
ChannelAuthMode::BotToken,
|
||||||
|
serde_json::json!({
|
||||||
|
"bot_token": "telegram-token-123",
|
||||||
|
"chat_id": " 987654 "
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("telegram connect should succeed");
|
||||||
|
|
||||||
|
assert_eq!(result.value.status, "connected");
|
||||||
|
assert!(result.value.restart_required);
|
||||||
|
|
||||||
|
let raw = tokio::fs::read_to_string(&config.config_path)
|
||||||
|
.await
|
||||||
|
.expect("saved config should exist");
|
||||||
|
let parsed: toml::Value = toml::from_str(&raw).expect("saved config should parse");
|
||||||
|
let telegram = parsed
|
||||||
|
.get("channels_config")
|
||||||
|
.and_then(|v| v.get("telegram"))
|
||||||
|
.and_then(toml::Value::as_table)
|
||||||
|
.expect("channels_config.telegram should be persisted");
|
||||||
|
|
||||||
|
// chat_id is trimmed before persistence (mirrors Discord channel_id).
|
||||||
|
assert_eq!(
|
||||||
|
telegram.get("chat_id").and_then(toml::Value::as_str),
|
||||||
|
Some("987654")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the persisted Discord `allowed_users` array from the saved config.toml.
|
||||||
|
async fn reload_discord_allowed_users(config: &Config) -> Vec<String> {
|
||||||
|
let raw = tokio::fs::read_to_string(&config.config_path)
|
||||||
|
.await
|
||||||
|
.expect("saved config should exist");
|
||||||
|
let parsed: toml::Value = toml::from_str(&raw).expect("saved config should parse");
|
||||||
|
parsed
|
||||||
|
.get("channels_config")
|
||||||
|
.and_then(|v| v.get("discord"))
|
||||||
|
.and_then(|v| v.get("allowed_users"))
|
||||||
|
.and_then(toml::Value::as_array)
|
||||||
|
.map(|arr| {
|
||||||
|
arr.iter()
|
||||||
|
.filter_map(toml::Value::as_str)
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seed_discord_with_allowlist(config: &mut Config) {
|
||||||
|
config.channels_config.discord = Some(DiscordConfig {
|
||||||
|
bot_token: "discord-token-abc".to_string(),
|
||||||
|
guild_id: None,
|
||||||
|
channel_id: None,
|
||||||
|
allowed_users: vec!["111".to_string(), "222".to_string()],
|
||||||
|
listen_to_bots: false,
|
||||||
|
mention_only: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_discord_omitted_allowlist_reuses_existing() {
|
||||||
|
// Reconnecting without resending `allowed_users` keeps the saved list — the
|
||||||
|
// reconnect-convenience path (#3794 review — Codex P2).
|
||||||
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
|
seed_discord_with_allowlist(&mut config);
|
||||||
|
config.save().await.expect("seed should persist");
|
||||||
|
|
||||||
|
connect_channel(
|
||||||
|
&config,
|
||||||
|
"discord",
|
||||||
|
ChannelAuthMode::BotToken,
|
||||||
|
serde_json::json!({ "bot_token": "discord-token-abc" }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("reconnect should succeed");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
reload_discord_allowed_users(&config).await,
|
||||||
|
vec!["111".to_string(), "222".to_string()],
|
||||||
|
"omitted allowed_users must reuse the previously-saved list"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_discord_cleared_allowlist_allows_everyone() {
|
||||||
|
// Clearing the allowlist in the UI submits an explicit empty value; the
|
||||||
|
// backend must honor it (empty ⇒ allow-all) instead of reusing the old list
|
||||||
|
// (#3794 review — Codex P2).
|
||||||
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
|
seed_discord_with_allowlist(&mut config);
|
||||||
|
config.save().await.expect("seed should persist");
|
||||||
|
|
||||||
|
connect_channel(
|
||||||
|
&config,
|
||||||
|
"discord",
|
||||||
|
ChannelAuthMode::BotToken,
|
||||||
|
serde_json::json!({ "bot_token": "discord-token-abc", "allowed_users": "" }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.expect("reconnect should succeed");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
reload_discord_allowed_users(&config).await.is_empty(),
|
||||||
|
"an explicit empty allowed_users must clear the list (allow-all), not reuse it"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn disconnect_discord_bot_token_clears_runtime_config() {
|
async fn disconnect_discord_bot_token_clears_runtime_config() {
|
||||||
let (_tmp, mut config) = isolated_test_config();
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
@@ -634,6 +749,168 @@ async fn channel_status_reports_managed_dm_credential_as_connected() {
|
|||||||
assert!(managed_dm.has_credentials);
|
assert!(managed_dm.has_credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue #3712: `channel_status` must reflect the *live* supervised-listener
|
||||||
|
// health, not just credential/config presence, so the Messaging tab never
|
||||||
|
// shows a false "Connected" while the listener is actually failing.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_listener_health_ignores_modes_without_a_listener() {
|
||||||
|
// managed-DM and other listener-less modes have no `channel:<id>` health
|
||||||
|
// component — presence must pass through untouched and never set an error.
|
||||||
|
assert_eq!(
|
||||||
|
merge_listener_health(true, false, Some("error"), Some("boom")),
|
||||||
|
(true, None)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
merge_listener_health(false, false, None, None),
|
||||||
|
(false, None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_listener_health_error_overrides_presence_and_surfaces_reason() {
|
||||||
|
// Configured (presence == connected) but the live listener is failing →
|
||||||
|
// report disconnected and carry the reason to the UI.
|
||||||
|
assert_eq!(
|
||||||
|
merge_listener_health(true, true, Some("error"), Some("gateway 4004")),
|
||||||
|
(false, Some("gateway 4004".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_listener_health_ok_confirms_connected() {
|
||||||
|
assert_eq!(
|
||||||
|
merge_listener_health(true, true, Some("ok"), None),
|
||||||
|
(true, None)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_listener_health_starting_keeps_presence() {
|
||||||
|
// Before the first connect attempt the component is "starting" (or absent):
|
||||||
|
// keep the presence-based value so a freshly-configured channel isn't shown
|
||||||
|
// as broken prematurely.
|
||||||
|
assert_eq!(
|
||||||
|
merge_listener_health(true, true, Some("starting"), None),
|
||||||
|
(true, None)
|
||||||
|
);
|
||||||
|
assert_eq!(merge_listener_health(true, true, None, None), (true, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn channel_status_surfaces_live_listener_error() {
|
||||||
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
|
|
||||||
|
// Configure a bot_token Discord channel (materialises a runtime listener).
|
||||||
|
config.channels_config.discord = Some(DiscordConfig {
|
||||||
|
bot_token: "tok".to_string(),
|
||||||
|
guild_id: None,
|
||||||
|
channel_id: None,
|
||||||
|
allowed_users: vec![],
|
||||||
|
listen_to_bots: false,
|
||||||
|
mention_only: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate the supervisor reporting the listener as failed.
|
||||||
|
crate::openhuman::health::mark_component_error("channel:discord", "gateway closed (4004)");
|
||||||
|
|
||||||
|
let result = channel_status(&config, Some("discord"))
|
||||||
|
.await
|
||||||
|
.expect("channel_status should succeed");
|
||||||
|
|
||||||
|
let bot_token = result
|
||||||
|
.value
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.auth_mode == ChannelAuthMode::BotToken)
|
||||||
|
.expect("bot_token entry");
|
||||||
|
assert!(
|
||||||
|
!bot_token.connected,
|
||||||
|
"a failing listener must report not-connected: {:?}",
|
||||||
|
result.value
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
bot_token.error.as_deref(),
|
||||||
|
Some("gateway closed (4004)"),
|
||||||
|
"the disconnect reason must be surfaced: {:?}",
|
||||||
|
result.value
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recovery: once the supervisor marks the listener healthy, status flips
|
||||||
|
// back to connected with the error cleared.
|
||||||
|
crate::openhuman::health::mark_component_ok("channel:discord");
|
||||||
|
let recovered = channel_status(&config, Some("discord"))
|
||||||
|
.await
|
||||||
|
.expect("channel_status should succeed");
|
||||||
|
let bot_token = recovered
|
||||||
|
.value
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.auth_mode == ChannelAuthMode::BotToken)
|
||||||
|
.expect("bot_token entry");
|
||||||
|
assert!(
|
||||||
|
bot_token.connected,
|
||||||
|
"healthy listener should report connected"
|
||||||
|
);
|
||||||
|
assert!(bot_token.error.is_none(), "error should clear on recovery");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Issue #3712: default messaging channel switch (Telegram↔Discord). Setting the
|
||||||
|
// default must persist to `channels_config.active_channel`; an unknown channel
|
||||||
|
// must be rejected without clobbering the current value.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_default_channel_persists_known_channels() {
|
||||||
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
|
assert!(config.channels_config.active_channel.is_none());
|
||||||
|
|
||||||
|
set_default_channel(&mut config, "Discord")
|
||||||
|
.await
|
||||||
|
.expect("set discord");
|
||||||
|
assert_eq!(
|
||||||
|
config.channels_config.active_channel.as_deref(),
|
||||||
|
Some("discord"),
|
||||||
|
"channel must be canonicalised to lowercase and persisted"
|
||||||
|
);
|
||||||
|
|
||||||
|
set_default_channel(&mut config, "telegram")
|
||||||
|
.await
|
||||||
|
.expect("set telegram");
|
||||||
|
assert_eq!(
|
||||||
|
config.channels_config.active_channel.as_deref(),
|
||||||
|
Some("telegram")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_default_channel_rejects_unknown_and_empty() {
|
||||||
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
|
set_default_channel(&mut config, "discord")
|
||||||
|
.await
|
||||||
|
.expect("seed discord");
|
||||||
|
|
||||||
|
assert!(set_default_channel(&mut config, "myspace")
|
||||||
|
.await
|
||||||
|
.unwrap_err()
|
||||||
|
.contains("unknown channel"),);
|
||||||
|
assert!(set_default_channel(&mut config, " ").await.is_err());
|
||||||
|
|
||||||
|
// A rejected set must not clobber the previously persisted value.
|
||||||
|
assert_eq!(
|
||||||
|
config.channels_config.active_channel.as_deref(),
|
||||||
|
Some("discord")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn get_default_channel_defaults_to_web_when_unset() {
|
||||||
|
let (_tmp, config) = isolated_test_config();
|
||||||
|
let out = get_default_channel(&config).expect("get default");
|
||||||
|
assert_eq!(out.value["active_channel"], "web");
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn connected_channel_slugs_merges_credentials_and_config() {
|
async fn connected_channel_slugs_merges_credentials_and_config() {
|
||||||
let (_tmp, mut config) = isolated_test_config();
|
let (_tmp, mut config) = isolated_test_config();
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ struct StatusParams {
|
|||||||
channel: Option<String>,
|
channel: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct SetDefaultParams {
|
||||||
|
channel: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct TestParams {
|
struct TestParams {
|
||||||
@@ -128,6 +134,8 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
|||||||
schemas("connect"),
|
schemas("connect"),
|
||||||
schemas("disconnect"),
|
schemas("disconnect"),
|
||||||
schemas("status"),
|
schemas("status"),
|
||||||
|
schemas("set_default"),
|
||||||
|
schemas("get_default"),
|
||||||
schemas("test"),
|
schemas("test"),
|
||||||
schemas("telegram_login_start"),
|
schemas("telegram_login_start"),
|
||||||
schemas("telegram_login_check"),
|
schemas("telegram_login_check"),
|
||||||
@@ -166,6 +174,14 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
|||||||
schema: schemas("status"),
|
schema: schemas("status"),
|
||||||
handler: handle_status,
|
handler: handle_status,
|
||||||
},
|
},
|
||||||
|
RegisteredController {
|
||||||
|
schema: schemas("set_default"),
|
||||||
|
handler: handle_set_default,
|
||||||
|
},
|
||||||
|
RegisteredController {
|
||||||
|
schema: schemas("get_default"),
|
||||||
|
handler: handle_get_default,
|
||||||
|
},
|
||||||
RegisteredController {
|
RegisteredController {
|
||||||
schema: schemas("test"),
|
schema: schemas("test"),
|
||||||
handler: handle_test,
|
handler: handle_test,
|
||||||
@@ -290,6 +306,30 @@ pub fn schemas(function: &str) -> ControllerSchema {
|
|||||||
"Array of status entries per channel and auth mode.",
|
"Array of status entries per channel and auth mode.",
|
||||||
)],
|
)],
|
||||||
},
|
},
|
||||||
|
"set_default" => ControllerSchema {
|
||||||
|
namespace: "channels",
|
||||||
|
function: "set_default",
|
||||||
|
description: "Set the default messaging channel for proactive agent \
|
||||||
|
delivery (persists active_channel + applies live).",
|
||||||
|
inputs: vec![required_string(
|
||||||
|
"channel",
|
||||||
|
"Channel identifier to make default (e.g. telegram, discord, web).",
|
||||||
|
)],
|
||||||
|
outputs: vec![json_output(
|
||||||
|
"result",
|
||||||
|
"Object with the new active_channel and restart_required flag.",
|
||||||
|
)],
|
||||||
|
},
|
||||||
|
"get_default" => ControllerSchema {
|
||||||
|
namespace: "channels",
|
||||||
|
function: "get_default",
|
||||||
|
description: "Get the persisted default messaging channel.",
|
||||||
|
inputs: vec![],
|
||||||
|
outputs: vec![json_output(
|
||||||
|
"result",
|
||||||
|
"Object with the current active_channel.",
|
||||||
|
)],
|
||||||
|
},
|
||||||
"test" => ControllerSchema {
|
"test" => ControllerSchema {
|
||||||
namespace: "channels",
|
namespace: "channels",
|
||||||
function: "test",
|
function: "test",
|
||||||
@@ -509,6 +549,21 @@ fn handle_status(params: Map<String, Value>) -> ControllerFuture {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_set_default(params: Map<String, Value>) -> ControllerFuture {
|
||||||
|
Box::pin(async move {
|
||||||
|
let mut config = config_rpc::load_config_with_timeout().await?;
|
||||||
|
let p = deserialize_params::<SetDefaultParams>(params)?;
|
||||||
|
to_json(ops::set_default_channel(&mut config, p.channel.trim()).await?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_get_default(_params: Map<String, Value>) -> ControllerFuture {
|
||||||
|
Box::pin(async move {
|
||||||
|
let config = config_rpc::load_config_with_timeout().await?;
|
||||||
|
to_json(ops::get_default_channel(&config)?)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_test(params: Map<String, Value>) -> ControllerFuture {
|
fn handle_test(params: Map<String, Value>) -> ControllerFuture {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let config = config_rpc::load_config_with_timeout().await?;
|
let config = config_rpc::load_config_with_timeout().await?;
|
||||||
|
|||||||
@@ -95,6 +95,63 @@ impl ProactiveMessageSubscriber {
|
|||||||
*guard = channel;
|
*guard = channel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Share this subscriber's active-channel handle so the runtime can update it
|
||||||
|
/// in place after construction (see [`register_active_channel_handle`]).
|
||||||
|
pub fn active_channel_handle(&self) -> Arc<RwLock<Option<String>>> {
|
||||||
|
Arc::clone(&self.active_channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle to the live proactive subscriber's `active_channel`, registered at
|
||||||
|
/// channel-runtime startup (issue #3712 — "switch default channel
|
||||||
|
/// Telegram↔Discord"). The `channels_set_default` RPC mutates this exact handle
|
||||||
|
/// via [`set_runtime_active_channel`] so a default-channel switch from the UI
|
||||||
|
/// takes effect without a restart. Only the full channel runtime registers
|
||||||
|
/// (the web-only subscriber can't deliver externally), and nothing registers in
|
||||||
|
/// unit tests — so [`set_runtime_active_channel`] is a no-op there and never
|
||||||
|
/// leaks across the parallel test suite. The choice is also persisted to
|
||||||
|
/// `config.channels_config.active_channel`, which seeds the handle on next start.
|
||||||
|
static ACTIVE_CHANNEL_HANDLE: std::sync::OnceLock<RwLock<Option<Arc<RwLock<Option<String>>>>>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
fn active_channel_handle_slot() -> &'static RwLock<Option<Arc<RwLock<Option<String>>>>> {
|
||||||
|
ACTIVE_CHANNEL_HANDLE.get_or_init(|| RwLock::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register the live subscriber's active-channel handle so the RPC can update it
|
||||||
|
/// at runtime. Called once from channel-runtime startup; the latest registration
|
||||||
|
/// wins.
|
||||||
|
pub fn register_active_channel_handle(handle: Arc<RwLock<Option<String>>>) {
|
||||||
|
if let Ok(mut slot) = active_channel_handle_slot().write() {
|
||||||
|
*slot = Some(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the live proactive subscriber's active channel. No-op when no
|
||||||
|
/// subscriber has registered a handle (e.g. unit tests, or before the channel
|
||||||
|
/// runtime starts) — config persistence still applies and the value is read at
|
||||||
|
/// next startup.
|
||||||
|
pub fn set_runtime_active_channel(channel: Option<String>) {
|
||||||
|
// Clone the Arc out and drop the slot read-guard before locking the handle,
|
||||||
|
// so we never hold two locks at once and the borrow doesn't outlive the read.
|
||||||
|
let handle = match active_channel_handle_slot().read() {
|
||||||
|
Ok(slot) => slot.clone(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let Some(handle) = handle else {
|
||||||
|
tracing::debug!("[proactive] set_runtime_active_channel: no live subscriber registered");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// Bind the guard out of the match (rather than `if let`) so the write-lock
|
||||||
|
// temporary is dropped before `handle`, avoiding an E0597 borrow on the
|
||||||
|
// local `handle`.
|
||||||
|
let mut guard = match handle.write() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
tracing::debug!(channel = ?channel, "[proactive] runtime active channel updated");
|
||||||
|
*guard = channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -159,6 +216,8 @@ impl EventHandler for ProactiveMessageSubscriber {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 2. If an active external channel is configured, deliver there too.
|
// 2. If an active external channel is configured, deliver there too.
|
||||||
|
// The `channels_set_default` RPC mutates this handle in place (issue
|
||||||
|
// #3712), so reading it here picks up a live default-channel switch.
|
||||||
let active = self
|
let active = self
|
||||||
.active_channel
|
.active_channel
|
||||||
.read()
|
.read()
|
||||||
@@ -173,6 +232,25 @@ impl EventHandler for ProactiveMessageSubscriber {
|
|||||||
|
|
||||||
let key = channel_name.to_ascii_lowercase();
|
let key = channel_name.to_ascii_lowercase();
|
||||||
if let Some(ch) = self.channels_by_name.get(&key) {
|
if let Some(ch) = self.channels_by_name.get(&key) {
|
||||||
|
// Resolve a delivery target before doing any work. Proactive
|
||||||
|
// sends carry no inbound recipient, so the channel must supply
|
||||||
|
// its configured default (e.g. Discord's `channel_id`). Channels
|
||||||
|
// with no resolvable target (e.g. Telegram, which has no stored
|
||||||
|
// default chat) are skipped with a warning rather than handed an
|
||||||
|
// empty recipient that would hit the platform API with a blank
|
||||||
|
// chat/channel id (#3794 review — Codex P2). Web delivery above
|
||||||
|
// already happened, so skipping only drops the external echo.
|
||||||
|
let Some(recipient) = ch.proactive_target() else {
|
||||||
|
tracing::warn!(
|
||||||
|
source = %source,
|
||||||
|
channel = %key,
|
||||||
|
"[proactive] active external channel has no configured \
|
||||||
|
delivery target for recipient-less proactive messages; \
|
||||||
|
skipping external delivery (web delivery unaffected)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
tracing::debug!(
|
tracing::debug!(
|
||||||
source = %source,
|
source = %source,
|
||||||
channel = %key,
|
channel = %key,
|
||||||
@@ -223,7 +301,7 @@ impl EventHandler for ProactiveMessageSubscriber {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let send_result = ch.send(&SendMessage::new(message, "")).await;
|
let send_result = ch.send(&SendMessage::new(message, &recipient)).await;
|
||||||
// Record the terminal status on the approval audit
|
// Record the terminal status on the approval audit
|
||||||
// row before we log the outcome — best-effort, see
|
// row before we log the outcome — best-effort, see
|
||||||
// #2135. `record_execution` itself logs write
|
// #2135. `record_execution` itself logs write
|
||||||
@@ -281,6 +359,31 @@ mod tests {
|
|||||||
struct MockChannel {
|
struct MockChannel {
|
||||||
name: String,
|
name: String,
|
||||||
send_count: Arc<AtomicUsize>,
|
send_count: Arc<AtomicUsize>,
|
||||||
|
/// Configured proactive delivery target. `Some` ⇒ the channel can
|
||||||
|
/// receive recipient-less proactive sends; `None` ⇒ proactive routing
|
||||||
|
/// skips it (models Telegram, which has no stored default chat).
|
||||||
|
target: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MockChannel {
|
||||||
|
/// A channel that *can* receive proactive sends (target defaults to its
|
||||||
|
/// own name, mirroring Discord's configured `channel_id`).
|
||||||
|
fn new(name: &str, send_count: Arc<AtomicUsize>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
send_count,
|
||||||
|
target: Some(name.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel with no resolvable proactive target (e.g. Telegram).
|
||||||
|
fn without_target(name: &str, send_count: Arc<AtomicUsize>) -> Self {
|
||||||
|
Self {
|
||||||
|
name: name.to_string(),
|
||||||
|
send_count,
|
||||||
|
target: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -288,6 +391,9 @@ mod tests {
|
|||||||
fn name(&self) -> &str {
|
fn name(&self) -> &str {
|
||||||
&self.name
|
&self.name
|
||||||
}
|
}
|
||||||
|
fn proactive_target(&self) -> Option<String> {
|
||||||
|
self.target.clone()
|
||||||
|
}
|
||||||
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
|
async fn send(&self, _message: &SendMessage) -> anyhow::Result<()> {
|
||||||
self.send_count.fetch_add(1, Ordering::SeqCst);
|
self.send_count.fetch_add(1, Ordering::SeqCst);
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -315,10 +421,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn routes_to_active_external_channel() {
|
async fn routes_to_active_external_channel() {
|
||||||
let send_count = Arc::new(AtomicUsize::new(0));
|
let send_count = Arc::new(AtomicUsize::new(0));
|
||||||
let ch: Arc<dyn Channel> = Arc::new(MockChannel {
|
let ch: Arc<dyn Channel> = Arc::new(MockChannel::new("telegram", Arc::clone(&send_count)));
|
||||||
name: "telegram".into(),
|
|
||||||
send_count: Arc::clone(&send_count),
|
|
||||||
});
|
|
||||||
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
||||||
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("telegram".into()));
|
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("telegram".into()));
|
||||||
|
|
||||||
@@ -327,13 +430,29 @@ mod tests {
|
|||||||
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
assert_eq!(send_count.load(Ordering::SeqCst), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn skips_external_when_channel_has_no_proactive_target() {
|
||||||
|
// The active channel is the configured default, but it has no resolvable
|
||||||
|
// delivery target (e.g. Telegram with no stored chat). Proactive routing
|
||||||
|
// must skip it rather than calling `send` with an empty recipient
|
||||||
|
// (#3794 review — Codex P2).
|
||||||
|
let send_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let ch: Arc<dyn Channel> = Arc::new(MockChannel::without_target(
|
||||||
|
"telegram",
|
||||||
|
Arc::clone(&send_count),
|
||||||
|
));
|
||||||
|
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
||||||
|
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("telegram".into()));
|
||||||
|
|
||||||
|
sub.handle(&proactive_event()).await;
|
||||||
|
|
||||||
|
assert_eq!(send_count.load(Ordering::SeqCst), 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skips_external_when_active_is_web() {
|
async fn skips_external_when_active_is_web() {
|
||||||
let send_count = Arc::new(AtomicUsize::new(0));
|
let send_count = Arc::new(AtomicUsize::new(0));
|
||||||
let ch: Arc<dyn Channel> = Arc::new(MockChannel {
|
let ch: Arc<dyn Channel> = Arc::new(MockChannel::new("telegram", Arc::clone(&send_count)));
|
||||||
name: "telegram".into(),
|
|
||||||
send_count: Arc::clone(&send_count),
|
|
||||||
});
|
|
||||||
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
||||||
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("web".into()));
|
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("web".into()));
|
||||||
|
|
||||||
@@ -346,10 +465,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn skips_external_when_active_is_none() {
|
async fn skips_external_when_active_is_none() {
|
||||||
let send_count = Arc::new(AtomicUsize::new(0));
|
let send_count = Arc::new(AtomicUsize::new(0));
|
||||||
let ch: Arc<dyn Channel> = Arc::new(MockChannel {
|
let ch: Arc<dyn Channel> = Arc::new(MockChannel::new("telegram", Arc::clone(&send_count)));
|
||||||
name: "telegram".into(),
|
|
||||||
send_count: Arc::clone(&send_count),
|
|
||||||
});
|
|
||||||
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
||||||
let sub = ProactiveMessageSubscriber::new(Arc::new(map), None);
|
let sub = ProactiveMessageSubscriber::new(Arc::new(map), None);
|
||||||
|
|
||||||
@@ -361,10 +477,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn runtime_update_active_channel() {
|
async fn runtime_update_active_channel() {
|
||||||
let send_count = Arc::new(AtomicUsize::new(0));
|
let send_count = Arc::new(AtomicUsize::new(0));
|
||||||
let ch: Arc<dyn Channel> = Arc::new(MockChannel {
|
let ch: Arc<dyn Channel> = Arc::new(MockChannel::new("discord", Arc::clone(&send_count)));
|
||||||
name: "discord".into(),
|
|
||||||
send_count: Arc::clone(&send_count),
|
|
||||||
});
|
|
||||||
let map: HashMap<String, Arc<dyn Channel>> = [("discord".into(), ch)].into();
|
let map: HashMap<String, Arc<dyn Channel>> = [("discord".into(), ch)].into();
|
||||||
let sub = ProactiveMessageSubscriber::new(Arc::new(map), None);
|
let sub = ProactiveMessageSubscriber::new(Arc::new(map), None);
|
||||||
|
|
||||||
@@ -381,10 +494,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn ignores_non_proactive_events() {
|
async fn ignores_non_proactive_events() {
|
||||||
let send_count = Arc::new(AtomicUsize::new(0));
|
let send_count = Arc::new(AtomicUsize::new(0));
|
||||||
let ch: Arc<dyn Channel> = Arc::new(MockChannel {
|
let ch: Arc<dyn Channel> = Arc::new(MockChannel::new("telegram", Arc::clone(&send_count)));
|
||||||
name: "telegram".into(),
|
|
||||||
send_count: Arc::clone(&send_count),
|
|
||||||
});
|
|
||||||
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
let map: HashMap<String, Arc<dyn Channel>> = [("telegram".into(), ch)].into();
|
||||||
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("telegram".into()));
|
let sub = ProactiveMessageSubscriber::new(Arc::new(map), Some("telegram".into()));
|
||||||
|
|
||||||
|
|||||||
@@ -42,10 +42,58 @@ impl DiscordChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a Discord user ID is in the allowlist.
|
/// Check if a Discord user ID is in the allowlist.
|
||||||
/// Empty list means deny everyone until explicitly configured.
|
///
|
||||||
/// `"*"` means allow everyone.
|
/// Empty list ⇒ allow-all: an unconfigured allowlist applies no per-user
|
||||||
|
/// restriction (the bot is still scoped to its configured guild/channel).
|
||||||
|
/// Previously an empty list denied *everyone*, so a bot connected via the UI
|
||||||
|
/// with the default-empty allowlist silently ignored every message and never
|
||||||
|
/// replied (issue #3712). This now matches the WhatsApp provider's
|
||||||
|
/// empty-⇒-allow-all convention. `"*"` also allows everyone; populate the
|
||||||
|
/// list with specific user IDs to restrict.
|
||||||
fn is_user_allowed(&self, user_id: &str) -> bool {
|
fn is_user_allowed(&self, user_id: &str) -> bool {
|
||||||
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
self.allowed_users.is_empty() || self.allowed_users.iter().any(|u| u == "*" || u == user_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide whether a message passes the bot's guild scoping.
|
||||||
|
///
|
||||||
|
/// - No configured guild → all messages pass (guild filter inactive).
|
||||||
|
/// - Configured guild, message in a *different* guild → blocked.
|
||||||
|
/// - Configured guild, message in that guild → allowed.
|
||||||
|
/// - Configured guild, DM (no `guild_id`) → only when the allowlist is
|
||||||
|
/// non-empty (explicit). `is_user_allowed` treats an empty list as
|
||||||
|
/// allow-all (the intended *within-guild* default), but DMs bypass the
|
||||||
|
/// guild filter — so a blank allowlist must not open a guild-scoped bot to
|
||||||
|
/// arbitrary DMs (#3794 review — Codex P1). `"*"` (non-empty) still allows.
|
||||||
|
fn passes_guild_scope(
|
||||||
|
configured_guild: Option<&str>,
|
||||||
|
msg_guild: Option<&str>,
|
||||||
|
allowlist_empty: bool,
|
||||||
|
) -> bool {
|
||||||
|
let Some(gid) = configured_guild else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
match msg_guild {
|
||||||
|
Some(g) => g == gid,
|
||||||
|
None => !allowlist_empty,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the outbound recipient channel id. Prefer the message's explicit
|
||||||
|
/// recipient (e.g. the channel a reply targets); fall back to the bot's
|
||||||
|
/// configured `channel_id` for recipient-less sends such as proactive
|
||||||
|
/// cron/heartbeat delivery (#3794 review — Codex P2). `None` when neither is
|
||||||
|
/// available, so the caller surfaces an error instead of POSTing to an empty
|
||||||
|
/// channel id.
|
||||||
|
fn resolve_recipient<'a>(
|
||||||
|
msg_recipient: &'a str,
|
||||||
|
configured: Option<&'a str>,
|
||||||
|
) -> Option<&'a str> {
|
||||||
|
let recipient = if msg_recipient.is_empty() {
|
||||||
|
configured.unwrap_or("")
|
||||||
|
} else {
|
||||||
|
msg_recipient
|
||||||
|
};
|
||||||
|
(!recipient.is_empty()).then_some(recipient)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn bot_user_id_from_token(token: &str) -> Option<String> {
|
fn bot_user_id_from_token(token: &str) -> Option<String> {
|
||||||
@@ -191,14 +239,34 @@ impl Channel for DiscordChannel {
|
|||||||
"discord"
|
"discord"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recipient-less proactive sends (cron/heartbeat) deliver to the bot's
|
||||||
|
/// configured default `channel_id`. `None` when unconfigured, so proactive
|
||||||
|
/// routing skips Discord rather than letting `send` bail on an empty target
|
||||||
|
/// (#3794 review — Codex P2).
|
||||||
|
fn proactive_target(&self) -> Option<String> {
|
||||||
|
self.channel_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
|
||||||
|
// Resolve the target channel: explicit recipient (replies) or the
|
||||||
|
// configured default channel (recipient-less proactive sends). Bail with
|
||||||
|
// a clear error rather than POSTing to an empty channel id (#3794 review).
|
||||||
|
let Some(recipient) =
|
||||||
|
Self::resolve_recipient(&message.recipient, self.channel_id.as_deref())
|
||||||
|
else {
|
||||||
|
anyhow::bail!(
|
||||||
|
"Discord send: no target channel — message had no recipient and no channel_id is configured"
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
let chunks = split_message_for_discord(&message.content);
|
let chunks = split_message_for_discord(&message.content);
|
||||||
|
|
||||||
for (i, chunk) in chunks.iter().enumerate() {
|
for (i, chunk) in chunks.iter().enumerate() {
|
||||||
let url = format!(
|
let url = format!("https://discord.com/api/v10/channels/{recipient}/messages");
|
||||||
"https://discord.com/api/v10/channels/{}/messages",
|
|
||||||
message.recipient
|
|
||||||
);
|
|
||||||
|
|
||||||
let body = json!({ "content": chunk });
|
let body = json!({ "content": chunk });
|
||||||
|
|
||||||
@@ -378,15 +446,13 @@ impl Channel for DiscordChannel {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Guild filter
|
// Guild filter + DM scoping (#3794 review — Codex P1)
|
||||||
if let Some(ref gid) = guild_filter {
|
if !Self::passes_guild_scope(
|
||||||
let msg_guild = d.get("guild_id").and_then(serde_json::Value::as_str);
|
guild_filter.as_deref(),
|
||||||
// DMs have no guild_id — let them through; for guild messages, enforce the filter
|
d.get("guild_id").and_then(serde_json::Value::as_str),
|
||||||
if let Some(g) = msg_guild {
|
self.allowed_users.is_empty(),
|
||||||
if g != gid {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channel filter — only process messages from the configured channel
|
// Channel filter — only process messages from the configured channel
|
||||||
|
|||||||
@@ -22,10 +22,13 @@ fn bot_user_id_extraction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_allowlist_denies_everyone() {
|
fn empty_allowlist_allows_everyone() {
|
||||||
|
// Issue #3712: an unconfigured (empty) allowlist must apply no per-user
|
||||||
|
// restriction — otherwise a UI-connected bot silently ignores every message
|
||||||
|
// and never replies. Scope is still enforced by guild/channel filters.
|
||||||
let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false);
|
let ch = DiscordChannel::new("fake".into(), None, None, vec![], false, false);
|
||||||
assert!(!ch.is_user_allowed("12345"));
|
assert!(ch.is_user_allowed("12345"));
|
||||||
assert!(!ch.is_user_allowed("anyone"));
|
assert!(ch.is_user_allowed("anyone"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -500,3 +503,73 @@ fn channel_id_defaults_to_none() {
|
|||||||
let ch = DiscordChannel::new("token".into(), None, None, vec![], false, false);
|
let ch = DiscordChannel::new("token".into(), None, None, vec![], false, false);
|
||||||
assert!(ch.channel_id.is_none());
|
assert!(ch.channel_id.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passes_guild_scope_covers_guild_dm_and_unscoped_cases() {
|
||||||
|
// No configured guild → everything passes (filter inactive).
|
||||||
|
assert!(DiscordChannel::passes_guild_scope(None, Some("g1"), true));
|
||||||
|
assert!(DiscordChannel::passes_guild_scope(None, None, true));
|
||||||
|
// Configured guild: same guild passes, other guild blocked.
|
||||||
|
assert!(DiscordChannel::passes_guild_scope(
|
||||||
|
Some("g1"),
|
||||||
|
Some("g1"),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
assert!(!DiscordChannel::passes_guild_scope(
|
||||||
|
Some("g1"),
|
||||||
|
Some("g2"),
|
||||||
|
true
|
||||||
|
));
|
||||||
|
// #3794 review (Codex P1): DM (no guild_id) under guild scope is blocked
|
||||||
|
// with a blank allowlist, allowed with an explicit one.
|
||||||
|
assert!(!DiscordChannel::passes_guild_scope(Some("g1"), None, true));
|
||||||
|
assert!(DiscordChannel::passes_guild_scope(Some("g1"), None, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_recipient_prefers_explicit_then_configured_channel() {
|
||||||
|
// #3794 review (Codex P2): recipient-less proactive sends fall back to the
|
||||||
|
// configured channel_id; an explicit recipient always wins.
|
||||||
|
assert_eq!(
|
||||||
|
DiscordChannel::resolve_recipient("123", Some("999")),
|
||||||
|
Some("123")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
DiscordChannel::resolve_recipient("", Some("999")),
|
||||||
|
Some("999")
|
||||||
|
);
|
||||||
|
// Neither available → None, so the caller errors instead of POSTing to "".
|
||||||
|
assert_eq!(DiscordChannel::resolve_recipient("", None), None);
|
||||||
|
assert_eq!(DiscordChannel::resolve_recipient("", Some("")), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proactive_target_uses_configured_channel_id() {
|
||||||
|
use crate::openhuman::channels::traits::Channel;
|
||||||
|
|
||||||
|
// Configured channel_id ⇒ recipient-less proactive sends have a target.
|
||||||
|
let with_channel = DiscordChannel::new(
|
||||||
|
"fake".into(),
|
||||||
|
None,
|
||||||
|
Some("12345".into()),
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(with_channel.proactive_target(), Some("12345".to_string()));
|
||||||
|
|
||||||
|
// No channel_id ⇒ None, so proactive routing skips Discord (#3794 Codex P2).
|
||||||
|
let no_channel = DiscordChannel::new("fake".into(), None, None, vec![], false, false);
|
||||||
|
assert_eq!(no_channel.proactive_target(), None);
|
||||||
|
|
||||||
|
// Whitespace-only channel_id is treated as unset.
|
||||||
|
let blank_channel = DiscordChannel::new(
|
||||||
|
"fake".into(),
|
||||||
|
None,
|
||||||
|
Some(" ".into()),
|
||||||
|
vec![],
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert_eq!(blank_channel.proactive_target(), None);
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ impl TelegramChannel {
|
|||||||
|
|
||||||
Self {
|
Self {
|
||||||
bot_token,
|
bot_token,
|
||||||
|
chat_id: None,
|
||||||
api_base,
|
api_base,
|
||||||
allowed_users: Arc::new(RwLock::new(normalized_allowed)),
|
allowed_users: Arc::new(RwLock::new(normalized_allowed)),
|
||||||
pairing,
|
pairing,
|
||||||
@@ -79,6 +80,16 @@ impl TelegramChannel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the default chat for recipient-less proactive sends. A blank or
|
||||||
|
/// whitespace-only value is treated as unset (`None`), so proactive routing
|
||||||
|
/// skips Telegram rather than POSTing to an empty `chat_id`.
|
||||||
|
pub fn with_chat_id(mut self, chat_id: Option<String>) -> Self {
|
||||||
|
self.chat_id = chat_id
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse reply_target into (chat_id, optional thread_id).
|
/// Parse reply_target into (chat_id, optional thread_id).
|
||||||
pub(crate) fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
|
pub(crate) fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
|
||||||
if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
|
if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ impl Channel for TelegramChannel {
|
|||||||
"telegram"
|
"telegram"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Recipient-less proactive sends (cron/heartbeat) deliver to the bot's
|
||||||
|
/// configured default `chat_id`. `None` when unconfigured, so proactive
|
||||||
|
/// routing skips Telegram rather than letting `send` POST to an empty
|
||||||
|
/// `chat_id` (mirrors Discord — #3712 Telegram parity).
|
||||||
|
fn proactive_target(&self) -> Option<String> {
|
||||||
|
self.chat_id
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
fn supports_reactions(&self) -> bool {
|
fn supports_reactions(&self) -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ fn telegram_channel_name() {
|
|||||||
assert_eq!(ch.name(), "telegram");
|
assert_eq!(ch.name(), "telegram");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proactive_target_uses_configured_chat_id() {
|
||||||
|
// Unset by default ⇒ proactive routing skips Telegram (#3712 parity).
|
||||||
|
let default = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
|
||||||
|
assert_eq!(default.proactive_target(), None);
|
||||||
|
|
||||||
|
// Configured chat_id ⇒ recipient-less proactive sends have a target.
|
||||||
|
let with_chat = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
|
||||||
|
.with_chat_id(Some("12345".into()));
|
||||||
|
assert_eq!(with_chat.proactive_target(), Some("12345".to_string()));
|
||||||
|
|
||||||
|
// Whitespace-only chat_id is normalized to unset.
|
||||||
|
let blank = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
|
||||||
|
.with_chat_id(Some(" ".into()));
|
||||||
|
assert_eq!(blank.proactive_target(), None);
|
||||||
|
|
||||||
|
// Explicit None passed to the builder stays unset.
|
||||||
|
let none =
|
||||||
|
TelegramChannel::new("fake-token".into(), vec!["*".into()], false).with_chat_id(None);
|
||||||
|
assert_eq!(none.proactive_target(), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn typing_handle_starts_as_none() {
|
fn typing_handle_starts_as_none() {
|
||||||
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
|
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ pub(crate) struct TelegramVoiceAttachment {
|
|||||||
/// Telegram channel — long-polls the Bot API for updates
|
/// Telegram channel — long-polls the Bot API for updates
|
||||||
pub struct TelegramChannel {
|
pub struct TelegramChannel {
|
||||||
pub(crate) bot_token: String,
|
pub(crate) bot_token: String,
|
||||||
|
/// Default chat for recipient-less proactive sends. `None` ⇒ proactive
|
||||||
|
/// routing skips Telegram (see `proactive_target`). Set from
|
||||||
|
/// `TelegramConfig::chat_id` via [`TelegramChannel::with_chat_id`].
|
||||||
|
pub(crate) chat_id: Option<String>,
|
||||||
/// Base URL for the Telegram Bot API. Defaults to `https://api.telegram.org`.
|
/// Base URL for the Telegram Bot API. Defaults to `https://api.telegram.org`.
|
||||||
/// Override via `OPENHUMAN_TELEGRAM_BOT_API_BASE` for E2E testing against a
|
/// Override via `OPENHUMAN_TELEGRAM_BOT_API_BASE` for E2E testing against a
|
||||||
/// mock server. The legacy `OPENHUMAN_TELEGRAM_API_BASE` alias is still accepted.
|
/// mock server. The legacy `OPENHUMAN_TELEGRAM_API_BASE` alias is still accepted.
|
||||||
|
|||||||
@@ -299,14 +299,41 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
|||||||
let local_embedding = config.workload_local_model("embeddings");
|
let local_embedding = config.workload_local_model("embeddings");
|
||||||
let embedding_api_key =
|
let embedding_api_key =
|
||||||
crate::openhuman::embeddings::resolve_api_key(&config, &config.memory.embedding_provider);
|
crate::openhuman::embeddings::resolve_api_key(&config, &config.memory.embedding_provider);
|
||||||
let mem: Arc<dyn Memory> = Arc::from(memory_store::create_memory_with_local_ai(
|
// Build the memory store. A misconfigured/removed embedding provider (e.g. a
|
||||||
|
// stale `embedding_provider = "fastembed"` that the factory no longer knows)
|
||||||
|
// makes the embedder build fail — but that must NOT take every messaging
|
||||||
|
// channel offline (issue #3712). Fall back to keyword-only memory
|
||||||
|
// (`embedding_provider = "none"` → NoopEmbedding) so the channel listeners
|
||||||
|
// still start; semantic memory degrades gracefully instead of the whole
|
||||||
|
// runtime aborting.
|
||||||
|
let mem: Arc<dyn Memory> = match memory_store::create_memory_with_local_ai(
|
||||||
&config.memory,
|
&config.memory,
|
||||||
local_embedding.as_deref(),
|
local_embedding.as_deref(),
|
||||||
&embedding_api_key,
|
&embedding_api_key,
|
||||||
&[],
|
&[],
|
||||||
Some(&config.storage.provider.config),
|
Some(&config.storage.provider.config),
|
||||||
&config.workspace_dir,
|
&config.workspace_dir,
|
||||||
)?);
|
) {
|
||||||
|
Ok(mem) => Arc::from(mem),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!(
|
||||||
|
error = %format!("{e:#}"),
|
||||||
|
provider = %config.memory.embedding_provider,
|
||||||
|
"[channels] memory embedder build failed — falling back to keyword-only \
|
||||||
|
memory so channels still start"
|
||||||
|
);
|
||||||
|
let mut fallback_memory = config.memory.clone();
|
||||||
|
fallback_memory.embedding_provider = "none".to_string();
|
||||||
|
Arc::from(memory_store::create_memory_with_local_ai(
|
||||||
|
&fallback_memory,
|
||||||
|
local_embedding.as_deref(),
|
||||||
|
&embedding_api_key,
|
||||||
|
&[],
|
||||||
|
Some(&config.storage.provider.config),
|
||||||
|
&config.workspace_dir,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
};
|
||||||
// Build system prompt from workspace identity files + skills
|
// Build system prompt from workspace identity files + skills
|
||||||
let workspace = config.workspace_dir.clone();
|
let workspace = config.workspace_dir.clone();
|
||||||
let tools_registry = Arc::new(tools::all_tools_with_runtime(
|
let tools_registry = Arc::new(tools::all_tools_with_runtime(
|
||||||
@@ -451,7 +478,8 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
|||||||
tg.stream_mode,
|
tg.stream_mode,
|
||||||
tg.draft_update_interval_ms,
|
tg.draft_update_interval_ms,
|
||||||
tg.silent_streaming,
|
tg.silent_streaming,
|
||||||
),
|
)
|
||||||
|
.with_chat_id(tg.chat_id.clone()),
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
@@ -692,12 +720,16 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
|||||||
// Register the proactive message subscriber so morning briefings,
|
// Register the proactive message subscriber so morning briefings,
|
||||||
// welcome messages, and other proactive agent output gets routed to
|
// welcome messages, and other proactive agent output gets routed to
|
||||||
// the user's active channel (+ always to web).
|
// the user's active channel (+ always to web).
|
||||||
let _proactive_handle = bus.subscribe(Arc::new(
|
let proactive_sub = crate::openhuman::channels::proactive::ProactiveMessageSubscriber::new(
|
||||||
crate::openhuman::channels::proactive::ProactiveMessageSubscriber::new(
|
Arc::clone(&channels_by_name),
|
||||||
Arc::clone(&channels_by_name),
|
config.channels_config.active_channel.clone(),
|
||||||
config.channels_config.active_channel.clone(),
|
);
|
||||||
),
|
// Expose its active-channel handle so the `channels_set_default` RPC can
|
||||||
));
|
// switch the default channel at runtime without a restart (issue #3712).
|
||||||
|
crate::openhuman::channels::proactive::register_active_channel_handle(
|
||||||
|
proactive_sub.active_channel_handle(),
|
||||||
|
);
|
||||||
|
let _proactive_handle = bus.subscribe(Arc::new(proactive_sub));
|
||||||
let _telegram_remote_handle = if channels_by_name.contains_key("telegram") {
|
let _telegram_remote_handle = if channels_by_name.contains_key("telegram") {
|
||||||
let handle = bus.subscribe(Arc::new(
|
let handle = bus.subscribe(Arc::new(
|
||||||
crate::openhuman::channels::providers::telegram::TelegramRemoteSubscriber::new(
|
crate::openhuman::channels::providers::telegram::TelegramRemoteSubscriber::new(
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ pub trait Channel: Send + Sync {
|
|||||||
/// Human-readable channel name
|
/// Human-readable channel name
|
||||||
fn name(&self) -> &str;
|
fn name(&self) -> &str;
|
||||||
|
|
||||||
|
/// Resolve the delivery target for a *recipient-less* proactive send (cron /
|
||||||
|
/// heartbeat), where the caller has no inbound message to reply to.
|
||||||
|
///
|
||||||
|
/// Returns the channel's configured default target (e.g. Discord's
|
||||||
|
/// `channel_id`) or `None` when the channel has no target it can deliver to
|
||||||
|
/// without an explicit recipient. Proactive routing skips channels that
|
||||||
|
/// return `None` instead of POSTing to an empty recipient (#3794 review —
|
||||||
|
/// Codex P2; Telegram has no configured default chat, so its `send` would
|
||||||
|
/// otherwise call the Bot API with an empty `chat_id`). Default `None` keeps
|
||||||
|
/// every existing provider opted out until it wires a real target.
|
||||||
|
fn proactive_target(&self) -> Option<String> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Send a message through this channel
|
/// Send a message through this channel
|
||||||
async fn send(&self, message: &SendMessage) -> anyhow::Result<()>;
|
async fn send(&self, message: &SendMessage) -> anyhow::Result<()>;
|
||||||
|
|
||||||
@@ -188,6 +202,9 @@ mod tests {
|
|||||||
.send(&SendMessage::new("hello", "bob"))
|
.send(&SendMessage::new("hello", "bob"))
|
||||||
.await
|
.await
|
||||||
.is_ok());
|
.is_ok());
|
||||||
|
// A provider that does not override `proactive_target` opts out of
|
||||||
|
// recipient-less proactive delivery (#3794).
|
||||||
|
assert_eq!(channel.proactive_target(), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ mod tests {
|
|||||||
fn reexported_channel_configs_are_constructible() {
|
fn reexported_channel_configs_are_constructible() {
|
||||||
let telegram = TelegramConfig {
|
let telegram = TelegramConfig {
|
||||||
bot_token: "token".into(),
|
bot_token: "token".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec!["alice".into()],
|
allowed_users: vec!["alice".into()],
|
||||||
stream_mode: StreamMode::default(),
|
stream_mode: StreamMode::default(),
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -113,6 +113,11 @@ fn default_silent_streaming() -> bool {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
pub struct TelegramConfig {
|
pub struct TelegramConfig {
|
||||||
pub bot_token: String,
|
pub bot_token: String,
|
||||||
|
/// Default chat for recipient-less *proactive* sends (morning briefings,
|
||||||
|
/// cron output, etc.). Mirrors `DiscordConfig::channel_id`: `None` ⇒ proactive
|
||||||
|
/// routing skips Telegram rather than POSTing to an empty `chat_id`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub chat_id: Option<String>,
|
||||||
pub allowed_users: Vec<String>,
|
pub allowed_users: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub stream_mode: StreamMode,
|
pub stream_mode: StreamMode,
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ fn has_listening_integrations_detects_telegram() {
|
|||||||
let mut cfg = ChannelsConfig::default();
|
let mut cfg = ChannelsConfig::default();
|
||||||
cfg.telegram = Some(TelegramConfig {
|
cfg.telegram = Some(TelegramConfig {
|
||||||
bot_token: "tok".into(),
|
bot_token: "tok".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec![],
|
allowed_users: vec![],
|
||||||
stream_mode: StreamMode::Off,
|
stream_mode: StreamMode::Off,
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -1818,6 +1818,7 @@ allowed_users = ["@admin"]
|
|||||||
};
|
};
|
||||||
cfg.channels_config.telegram = Some(TelegramConfig {
|
cfg.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token: known_secret.to_string(),
|
bot_token: known_secret.to_string(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec!["@admin".to_string()],
|
allowed_users: vec!["@admin".to_string()],
|
||||||
stream_mode: StreamMode::Off,
|
stream_mode: StreamMode::Off,
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -496,7 +496,7 @@ pub async fn consume_login_token(
|
|||||||
serde_json::json!({ "jwtToken": jwt_token }),
|
serde_json::json!({ "jwtToken": jwt_token }),
|
||||||
vec![
|
vec![
|
||||||
format!(
|
format!(
|
||||||
"login token consumed via POST /telegram/login-tokens/:token/consume on {}",
|
"login token consumed via POST /auth/login-token/consume on {}",
|
||||||
api_url.trim_end_matches('/')
|
api_url.trim_end_matches('/')
|
||||||
),
|
),
|
||||||
"session JWT received".to_string(),
|
"session JWT received".to_string(),
|
||||||
|
|||||||
@@ -590,6 +590,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
config.channels_config.telegram = Some(TelegramConfig {
|
config.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token: "test-token".into(),
|
bot_token: "test-token".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: allowed,
|
allowed_users: allowed,
|
||||||
stream_mode: Default::default(),
|
stream_mode: Default::default(),
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Migration 6 → 7: retire the removed `"fastembed"` embedding provider.
|
||||||
|
//!
|
||||||
|
//! ## The problem
|
||||||
|
//!
|
||||||
|
//! Older builds shipped a local `"fastembed"` embedding provider (BGE models,
|
||||||
|
//! 384 dims). It has since been removed from the binary entirely — it is not a
|
||||||
|
//! cargo feature, it simply no longer exists in
|
||||||
|
//! [`crate::openhuman::embeddings::factory::create_embedding_provider`], which
|
||||||
|
//! hard-errors on any unknown provider string.
|
||||||
|
//!
|
||||||
|
//! Users who selected (or defaulted to) `"fastembed"` on an older build keep
|
||||||
|
//! `embedding_provider = "fastembed"` in their persisted `config.toml`. On
|
||||||
|
//! upgrade, the channel runtime's memory store build calls the factory with
|
||||||
|
//! that stale value → `Err("unknown embedding provider: \"fastembed\"")` →
|
||||||
|
//! `start_channels` aborts → **all messaging channels (Telegram/Discord) go
|
||||||
|
//! offline** with no surfaced error (issue #3712).
|
||||||
|
//!
|
||||||
|
//! ## What this migration does
|
||||||
|
//!
|
||||||
|
//! A pure, idempotent mutation of the persisted `Config`: when
|
||||||
|
//! `memory.embedding_provider` is the removed `"fastembed"` value, rewrite it to
|
||||||
|
//! a still-supported provider and reset the model/dimensions accordingly, since
|
||||||
|
//! the legacy BGE model/384-dim values are incompatible with both targets.
|
||||||
|
//!
|
||||||
|
//! **Target selection (offline-aware).** `fastembed` was a *local* embedder, so
|
||||||
|
//! a config carrying it belonged to a user who chose local/offline embeddings.
|
||||||
|
//! To preserve that intent, the caller (`run_pending`) probes for a reachable
|
||||||
|
//! local Ollama server and passes `prefer_local`:
|
||||||
|
//! - `prefer_local = true` → `"ollama"` + `bge-m3` (1024-dim; Ollama auto-pulls
|
||||||
|
//! the model on first embed). Keeps the user local/offline.
|
||||||
|
//! - `prefer_local = false` → `"managed"` cloud backend + cloud defaults (the
|
||||||
|
//! fresh-install default), used when no local Ollama is reachable.
|
||||||
|
//!
|
||||||
|
//! Both targets are 1024-dim — matching the memory tree's fixed on-disk
|
||||||
|
//! `EMBEDDING_DIM=1024`. This step only ever rewrites `fastembed`, so cloud-only
|
||||||
|
//! users (never on `fastembed`) are untouched.
|
||||||
|
//!
|
||||||
|
//! Stored vectors written at the old signature are left in place: they are
|
||||||
|
//! ignored by signature-filtered vector search and re-generated lazily by the
|
||||||
|
//! existing re-embed backfill ([`crate::openhuman::memory_queue::ensure_reembed_backfill`])
|
||||||
|
//! once memory next syncs. No DB surgery happens here — this mirrors the
|
||||||
|
//! pure-config-mutation contract of the other migration steps.
|
||||||
|
//!
|
||||||
|
//! ## Behaviour
|
||||||
|
//!
|
||||||
|
//! - `run` is a **pure, synchronous** in-memory mutation of `Config`; the caller
|
||||||
|
//! (`migrations::run_pending`) persists via `Config::save()` and bumps
|
||||||
|
//! `schema_version`. The (impure) reachability probe lives in
|
||||||
|
//! [`local_ollama_reachable`] and is invoked by the caller, not by `run`.
|
||||||
|
//! - Idempotent: once rewritten the provider is no longer `"fastembed"`, so a
|
||||||
|
//! second run is a no-op.
|
||||||
|
//! - Never touches keys/secrets or any other config field.
|
||||||
|
|
||||||
|
use crate::openhuman::config::Config;
|
||||||
|
use crate::openhuman::embeddings::{
|
||||||
|
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, DEFAULT_OLLAMA_DIMENSIONS,
|
||||||
|
DEFAULT_OLLAMA_MODEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The removed provider value that must not reach the embedding factory.
|
||||||
|
const REMOVED_PROVIDER: &str = "fastembed";
|
||||||
|
|
||||||
|
/// Managed cloud backend — the current fresh-install default. Used as the
|
||||||
|
/// rewrite target when no local Ollama is reachable. Matches
|
||||||
|
/// `create_embedding_provider`'s accepted name.
|
||||||
|
const MANAGED_PROVIDER: &str = "managed";
|
||||||
|
|
||||||
|
/// Local Ollama backend — the rewrite target when a local Ollama server is
|
||||||
|
/// reachable, preserving the offline intent of a `fastembed` config. Matches
|
||||||
|
/// `create_embedding_provider`'s accepted name.
|
||||||
|
const OLLAMA_PROVIDER: &str = "ollama";
|
||||||
|
|
||||||
|
/// Counters returned by [`run`] for diagnostics. Logged at INFO once per run.
|
||||||
|
#[derive(Debug, Default, Clone)]
|
||||||
|
pub struct MigrationStats {
|
||||||
|
/// Whether the removed `"fastembed"` provider was rewritten.
|
||||||
|
pub provider_migrated: bool,
|
||||||
|
/// Whether the rewrite target was local Ollama (`true`) vs managed cloud
|
||||||
|
/// (`false`). Only meaningful when `provider_migrated`.
|
||||||
|
pub migrated_to_local: bool,
|
||||||
|
/// Embedding dimensionality before the rewrite (for the log line).
|
||||||
|
pub old_dimensions: usize,
|
||||||
|
/// Embedding dimensionality after the rewrite.
|
||||||
|
pub new_dimensions: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite a persisted `"fastembed"` embedding provider to a still-supported
|
||||||
|
/// backend.
|
||||||
|
///
|
||||||
|
/// `prefer_local` selects the target (see the module docs): `true` ⇒ local
|
||||||
|
/// Ollama (`bge-m3`, preserving offline intent), `false` ⇒ managed cloud. The
|
||||||
|
/// caller derives `prefer_local` from [`local_ollama_reachable`].
|
||||||
|
///
|
||||||
|
/// Synchronous — pure config mutation, no I/O. Caller persists via
|
||||||
|
/// `Config::save()` once `schema_version` is also bumped.
|
||||||
|
///
|
||||||
|
/// Returns `anyhow::Result` for uniformity with the other migration steps in
|
||||||
|
/// [`super`]; this pass has no fallible operations today and always returns
|
||||||
|
/// `Ok`.
|
||||||
|
pub fn run(config: &mut Config, prefer_local: bool) -> anyhow::Result<MigrationStats> {
|
||||||
|
let mut stats = MigrationStats {
|
||||||
|
old_dimensions: config.memory.embedding_dimensions,
|
||||||
|
new_dimensions: config.memory.embedding_dimensions,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
if !config
|
||||||
|
.memory
|
||||||
|
.embedding_provider
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case(REMOVED_PROVIDER)
|
||||||
|
{
|
||||||
|
log::debug!(
|
||||||
|
"[migrations][legacy-embedding] embedding_provider is not the removed \
|
||||||
|
\"{REMOVED_PROVIDER}\" — nothing to do"
|
||||||
|
);
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both targets are 1024-dim (the memory tree's fixed on-disk EMBEDDING_DIM);
|
||||||
|
// the legacy 384-dim BGE values are incompatible with either, so stored
|
||||||
|
// vectors re-embed lazily via backfill regardless of which target we pick.
|
||||||
|
let (provider, model, dimensions) = if prefer_local {
|
||||||
|
(
|
||||||
|
OLLAMA_PROVIDER,
|
||||||
|
DEFAULT_OLLAMA_MODEL,
|
||||||
|
DEFAULT_OLLAMA_DIMENSIONS,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
MANAGED_PROVIDER,
|
||||||
|
DEFAULT_CLOUD_EMBEDDING_MODEL,
|
||||||
|
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
config.memory.embedding_provider = provider.to_string();
|
||||||
|
config.memory.embedding_model = model.to_string();
|
||||||
|
config.memory.embedding_dimensions = dimensions;
|
||||||
|
|
||||||
|
stats.provider_migrated = true;
|
||||||
|
stats.migrated_to_local = prefer_local;
|
||||||
|
stats.new_dimensions = dimensions;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[migrations][legacy-embedding] embedding_provider \"{REMOVED_PROVIDER}\" -> \
|
||||||
|
\"{provider}\" (model={model}, dims {} -> {}, local={prefer_local}); \
|
||||||
|
stale vectors re-embed lazily via backfill",
|
||||||
|
stats.old_dimensions,
|
||||||
|
stats.new_dimensions,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Best-effort probe: is a local Ollama server reachable at `base_url`?
|
||||||
|
///
|
||||||
|
/// Invoked by [`super::run_pending`] to choose the [`run`] target — a reachable
|
||||||
|
/// local Ollama lets former-`fastembed` (i.e. local-embedding) users stay local
|
||||||
|
/// (`bge-m3`, auto-pulled on first embed) instead of being forced onto the
|
||||||
|
/// managed cloud backend. Bounded (1.5s) and non-fatal: any client-build error,
|
||||||
|
/// transport error, timeout, or non-2xx status ⇒ `false`, so the caller falls
|
||||||
|
/// back to managed. Kept out of [`run`] so the rewrite itself stays pure/sync.
|
||||||
|
pub(crate) async fn local_ollama_reachable(base_url: &str) -> bool {
|
||||||
|
let url = format!("{}/api/tags", base_url.trim_end_matches('/'));
|
||||||
|
let client = match reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_millis(1500))
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(client) => client,
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!("[migrations][legacy-embedding] ollama probe client build failed: {error}");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(resp) => {
|
||||||
|
let reachable = resp.status().is_success();
|
||||||
|
log::debug!(
|
||||||
|
"[migrations][legacy-embedding] ollama probe {url} -> {} (reachable={reachable})",
|
||||||
|
resp.status()
|
||||||
|
);
|
||||||
|
reachable
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::debug!("[migrations][legacy-embedding] ollama probe {url} failed: {error}");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "migrate_legacy_embedding_provider_tests.rs"]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::openhuman::embeddings::{
|
||||||
|
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS, DEFAULT_CLOUD_EMBEDDING_MODEL, DEFAULT_OLLAMA_DIMENSIONS,
|
||||||
|
DEFAULT_OLLAMA_MODEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn config_with_provider(provider: &str, model: &str, dims: usize) -> Config {
|
||||||
|
let mut config = Config::default();
|
||||||
|
config.memory.embedding_provider = provider.to_string();
|
||||||
|
config.memory.embedding_model = model.to_string();
|
||||||
|
config.memory.embedding_dimensions = dims;
|
||||||
|
config
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrites_fastembed_to_managed_with_cloud_defaults() {
|
||||||
|
let mut config = config_with_provider("fastembed", "BGESmallENV15", 384);
|
||||||
|
|
||||||
|
// No local Ollama reachable ⇒ managed cloud target.
|
||||||
|
let stats = run(&mut config, false).expect("migration should succeed");
|
||||||
|
|
||||||
|
assert!(stats.provider_migrated, "fastembed must be migrated");
|
||||||
|
assert!(!stats.migrated_to_local, "managed target is not local");
|
||||||
|
assert_eq!(stats.old_dimensions, 384);
|
||||||
|
assert_eq!(stats.new_dimensions, DEFAULT_CLOUD_EMBEDDING_DIMENSIONS);
|
||||||
|
assert_eq!(config.memory.embedding_provider, "managed");
|
||||||
|
assert_eq!(config.memory.embedding_model, DEFAULT_CLOUD_EMBEDDING_MODEL);
|
||||||
|
assert_eq!(
|
||||||
|
config.memory.embedding_dimensions,
|
||||||
|
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrites_fastembed_to_ollama_when_local_preferred() {
|
||||||
|
let mut config = config_with_provider("fastembed", "BGESmallENV15", 384);
|
||||||
|
|
||||||
|
// A reachable local Ollama ⇒ stay local (preserve offline intent).
|
||||||
|
let stats = run(&mut config, true).expect("migration should succeed");
|
||||||
|
|
||||||
|
assert!(stats.provider_migrated, "fastembed must be migrated");
|
||||||
|
assert!(stats.migrated_to_local, "ollama target is local");
|
||||||
|
assert_eq!(stats.old_dimensions, 384);
|
||||||
|
assert_eq!(stats.new_dimensions, DEFAULT_OLLAMA_DIMENSIONS);
|
||||||
|
assert_eq!(config.memory.embedding_provider, "ollama");
|
||||||
|
assert_eq!(config.memory.embedding_model, DEFAULT_OLLAMA_MODEL);
|
||||||
|
assert_eq!(
|
||||||
|
config.memory.embedding_dimensions,
|
||||||
|
DEFAULT_OLLAMA_DIMENSIONS
|
||||||
|
);
|
||||||
|
// Both targets land on the memory tree's fixed 1024-dim format.
|
||||||
|
assert_eq!(
|
||||||
|
DEFAULT_OLLAMA_DIMENSIONS,
|
||||||
|
DEFAULT_CLOUD_EMBEDDING_DIMENSIONS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_idempotent() {
|
||||||
|
let mut config = config_with_provider("fastembed", "BGESmallENV15", 384);
|
||||||
|
run(&mut config, false).expect("first run");
|
||||||
|
let stats = run(&mut config, false).expect("second run");
|
||||||
|
assert!(
|
||||||
|
!stats.provider_migrated,
|
||||||
|
"second run must be a no-op once provider is rewritten"
|
||||||
|
);
|
||||||
|
assert_eq!(config.memory.embedding_provider, "managed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_idempotent_after_local_rewrite() {
|
||||||
|
let mut config = config_with_provider("fastembed", "BGESmallENV15", 384);
|
||||||
|
run(&mut config, true).expect("first run");
|
||||||
|
// Provider is now "ollama" (not "fastembed"), so a second run is a no-op
|
||||||
|
// regardless of the prefer_local flag.
|
||||||
|
let stats = run(&mut config, false).expect("second run");
|
||||||
|
assert!(!stats.provider_migrated, "second run must be a no-op");
|
||||||
|
assert_eq!(config.memory.embedding_provider, "ollama");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn matches_case_insensitively_and_trims() {
|
||||||
|
let mut config = config_with_provider(" FastEmbed ", "BGESmallENV15", 384);
|
||||||
|
let stats = run(&mut config, false).expect("migration should succeed");
|
||||||
|
assert!(stats.provider_migrated);
|
||||||
|
assert_eq!(config.memory.embedding_provider, "managed");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn leaves_valid_providers_untouched() {
|
||||||
|
for provider in ["managed", "ollama", "voyage", "none", "openai"] {
|
||||||
|
// Untouched regardless of the prefer_local flag — only "fastembed" is rewritten.
|
||||||
|
for prefer_local in [false, true] {
|
||||||
|
let mut config = config_with_provider(provider, "some-model", 1024);
|
||||||
|
let stats = run(&mut config, prefer_local).expect("migration should succeed");
|
||||||
|
assert!(!stats.provider_migrated, "{provider} must not be migrated");
|
||||||
|
assert_eq!(config.memory.embedding_provider, provider);
|
||||||
|
assert_eq!(config.memory.embedding_model, "some-model");
|
||||||
|
assert_eq!(config.memory.embedding_dimensions, 1024);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reachable_probe_false_when_no_server() {
|
||||||
|
// Nothing listening ⇒ connection refused ⇒ false (caller falls back to managed).
|
||||||
|
assert!(!local_ollama_reachable("http://127.0.0.1:1").await);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn reachable_probe_true_for_2xx_server() {
|
||||||
|
use axum::{routing::get, Router};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
|
||||||
|
let app = Router::new().route("/api/tags", get(|| async { "{\"models\":[]}" }));
|
||||||
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Trailing slash exercises the `trim_end_matches('/')` join.
|
||||||
|
assert!(local_ollama_reachable(&format!("http://{addr}/")).await);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
use crate::openhuman::config::Config;
|
use crate::openhuman::config::Config;
|
||||||
|
|
||||||
mod expand_autonomy_defaults;
|
mod expand_autonomy_defaults;
|
||||||
|
mod migrate_legacy_embedding_provider;
|
||||||
mod phase_out_profile_md;
|
mod phase_out_profile_md;
|
||||||
mod reconcile_orphaned_providers;
|
mod reconcile_orphaned_providers;
|
||||||
mod remove_write_auto_approve;
|
mod remove_write_auto_approve;
|
||||||
@@ -32,7 +33,7 @@ mod retire_chat_v1_model;
|
|||||||
mod unify_ai_provider_settings;
|
mod unify_ai_provider_settings;
|
||||||
|
|
||||||
/// Current target schema version. Bumped alongside every new migration.
|
/// Current target schema version. Bumped alongside every new migration.
|
||||||
pub const CURRENT_SCHEMA_VERSION: u32 = 6;
|
pub const CURRENT_SCHEMA_VERSION: u32 = 7;
|
||||||
|
|
||||||
/// Run any migrations whose `schema_version` gate hasn't yet been
|
/// Run any migrations whose `schema_version` gate hasn't yet been
|
||||||
/// crossed for this workspace.
|
/// crossed for this workspace.
|
||||||
@@ -326,6 +327,60 @@ pub async fn run_pending(config: &mut Config) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6 -> 7: retire the removed `"fastembed"` embedding provider. Older builds
|
||||||
|
// shipped a local fastembed provider; it no longer exists in the embedding
|
||||||
|
// factory, which hard-errors on unknown provider strings. A persisted
|
||||||
|
// `embedding_provider = "fastembed"` therefore aborts `start_channels`'
|
||||||
|
// memory build and takes every messaging channel offline (issue #3712).
|
||||||
|
// `fastembed` was a *local* embedder, so prefer a still-local target when a
|
||||||
|
// local Ollama server is reachable (preserves the user's offline intent);
|
||||||
|
// otherwise fall back to the managed cloud default. The probe is bounded and
|
||||||
|
// best-effort, and only runs for `fastembed` configs (the only ones this step
|
||||||
|
// rewrites) so unaffected upgrades pay no network cost. Guard on `== 6` so an
|
||||||
|
// earlier failed step doesn't get skipped.
|
||||||
|
if config.schema_version == 6 {
|
||||||
|
let prefer_local = if config
|
||||||
|
.memory
|
||||||
|
.embedding_provider
|
||||||
|
.trim()
|
||||||
|
.eq_ignore_ascii_case("fastembed")
|
||||||
|
{
|
||||||
|
let base = crate::openhuman::inference::local::ollama_base_url_from_config(config);
|
||||||
|
migrate_legacy_embedding_provider::local_ollama_reachable(&base).await
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
match migrate_legacy_embedding_provider::run(config, prefer_local) {
|
||||||
|
Ok(stats) => {
|
||||||
|
let previous_version = config.schema_version;
|
||||||
|
config.schema_version = 7;
|
||||||
|
if let Err(err) = config.save().await {
|
||||||
|
config.schema_version = previous_version;
|
||||||
|
log::warn!(
|
||||||
|
"[migrations] migrate_legacy_embedding_provider ran but config.save \
|
||||||
|
failed: {err:#} — rolled in-memory schema_version back to \
|
||||||
|
{previous_version}, will retry on next launch"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log::info!(
|
||||||
|
"[migrations] schema_version bumped to 7 (migrate_legacy_embedding_provider \
|
||||||
|
provider_migrated={} migrated_to_local={} old_dims={} new_dims={})",
|
||||||
|
stats.provider_migrated,
|
||||||
|
stats.migrated_to_local,
|
||||||
|
stats.old_dimensions,
|
||||||
|
stats.new_dimensions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
log::warn!(
|
||||||
|
"[migrations] migrate_legacy_embedding_provider failed: {err:#} — \
|
||||||
|
will retry on next launch"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -113,8 +113,8 @@ async fn run_pending_runs_phase_out_when_version_zero() {
|
|||||||
|
|
||||||
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
|
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
on_disk.contains("schema_version = 6"),
|
on_disk.contains("schema_version = 7"),
|
||||||
"saved config.toml must record schema_version=6, got:\n{on_disk}"
|
"saved config.toml must record schema_version=7, got:\n{on_disk}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,7 +129,34 @@ async fn run_pending_bumps_version_on_fresh_install() {
|
|||||||
|
|
||||||
assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
|
assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
|
||||||
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
|
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
|
||||||
assert!(on_disk.contains("schema_version = 6"));
|
assert!(on_disk.contains("schema_version = 7"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn run_pending_migrates_fastembed_to_managed_without_local_ollama() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
fs::create_dir_all(tmp.path().join("workspace")).unwrap();
|
||||||
|
|
||||||
|
let mut config = config_in(&tmp);
|
||||||
|
config.schema_version = 6;
|
||||||
|
config.memory.embedding_provider = "fastembed".to_string();
|
||||||
|
config.memory.embedding_model = "BGESmallENV15".to_string();
|
||||||
|
config.memory.embedding_dimensions = 384;
|
||||||
|
// Point the local-Ollama probe at a guaranteed-dead address so the rewrite
|
||||||
|
// target is deterministic (managed) regardless of whether the host happens
|
||||||
|
// to run Ollama on the default port.
|
||||||
|
config.local_ai.base_url = Some("http://127.0.0.1:1".to_string());
|
||||||
|
|
||||||
|
run_pending(&mut config).await;
|
||||||
|
|
||||||
|
assert_eq!(config.schema_version, 7);
|
||||||
|
assert_eq!(
|
||||||
|
config.memory.embedding_provider, "managed",
|
||||||
|
"no reachable local Ollama ⇒ managed cloud target"
|
||||||
|
);
|
||||||
|
assert_eq!(config.memory.embedding_dimensions, 1024);
|
||||||
|
let on_disk = std::fs::read_to_string(&config.config_path).unwrap();
|
||||||
|
assert!(on_disk.contains("schema_version = 7"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -253,8 +280,8 @@ async fn run_pending_expands_autonomy_defaults_from_v3() {
|
|||||||
// On-disk config must reflect the new schema_version.
|
// On-disk config must reflect the new schema_version.
|
||||||
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
on_disk.contains("schema_version = 6"),
|
on_disk.contains("schema_version = 7"),
|
||||||
"saved config.toml must record schema_version=6, got:\n{on_disk}"
|
"saved config.toml must record schema_version=7, got:\n{on_disk}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,8 +313,8 @@ async fn run_pending_v4_to_v5_removes_write_tools_from_auto_approve() {
|
|||||||
|
|
||||||
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
on_disk.contains("schema_version = 6"),
|
on_disk.contains("schema_version = 7"),
|
||||||
"saved config.toml must record schema_version=6, got:\n{on_disk}"
|
"saved config.toml must record schema_version=7, got:\n{on_disk}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,8 +349,8 @@ async fn run_pending_v5_to_v6_repairs_http_request_limits() {
|
|||||||
// The version bump must be persisted to disk too.
|
// The version bump must be persisted to disk too.
|
||||||
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
on_disk.contains("schema_version = 6"),
|
on_disk.contains("schema_version = 7"),
|
||||||
"saved config.toml must record schema_version=6, got:\n{on_disk}"
|
"saved config.toml must record schema_version=7, got:\n{on_disk}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -357,8 +384,8 @@ async fn run_pending_v5_to_v6_reconciles_orphaned_providers() {
|
|||||||
|
|
||||||
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
let on_disk = fs::read_to_string(&config.config_path).unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
on_disk.contains("schema_version = 6"),
|
on_disk.contains("schema_version = 7"),
|
||||||
"saved config.toml must record schema_version=6, got:\n{on_disk}"
|
"saved config.toml must record schema_version=7, got:\n{on_disk}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -154,12 +154,9 @@ async fn serve_mock_backend() -> (
|
|||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/auth/me", get(mock_auth_me))
|
.route("/auth/me", get(mock_auth_me))
|
||||||
.route("/api/auth/me", get(mock_auth_me))
|
.route("/api/auth/me", get(mock_auth_me))
|
||||||
|
.route("/auth/login-token/consume", post(mock_consume_login_token))
|
||||||
.route(
|
.route(
|
||||||
"/telegram/login-tokens/{token}/consume",
|
"/api/auth/login-token/consume",
|
||||||
post(mock_consume_login_token),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/telegram/login-tokens/{token}/consume",
|
|
||||||
post(mock_consume_login_token),
|
post(mock_consume_login_token),
|
||||||
)
|
)
|
||||||
.route(
|
.route(
|
||||||
@@ -370,11 +367,17 @@ async fn static_auth_me(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn mock_consume_login_token(AxumPath(token): AxumPath<String>) -> Json<Value> {
|
async fn mock_consume_login_token(Json(body): Json<Value>) -> Json<Value> {
|
||||||
|
// Token now arrives in the JSON body (`{ token }`), not the URL path, and the
|
||||||
|
// response field is `jwt` (matches backend `routes/auth.ts`).
|
||||||
|
let token = body
|
||||||
|
.get("token")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or_default();
|
||||||
Json(json!({
|
Json(json!({
|
||||||
"success": true,
|
"success": true,
|
||||||
"data": {
|
"data": {
|
||||||
"jwtToken": format!("jwt-from-{token}")
|
"jwt": format!("jwt-from-{token}")
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -2106,6 +2109,7 @@ async fn config_save_and_load_encrypts_channel_secret_fields() {
|
|||||||
config.search.querit.api_key = Some("querit-secret".into());
|
config.search.querit.api_key = Some("querit-secret".into());
|
||||||
config.channels_config.telegram = Some(TelegramConfig {
|
config.channels_config.telegram = Some(TelegramConfig {
|
||||||
bot_token: "telegram-secret".into(),
|
bot_token: "telegram-secret".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec!["alice".into()],
|
allowed_users: vec!["alice".into()],
|
||||||
stream_mode: Default::default(),
|
stream_mode: Default::default(),
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ async fn config_secrets_roundtrip_via_keyring_backed_master_key_migration() {
|
|||||||
channels_config: openhuman_core::openhuman::config::schema::ChannelsConfig {
|
channels_config: openhuman_core::openhuman::config::schema::ChannelsConfig {
|
||||||
telegram: Some(TelegramConfig {
|
telegram: Some(TelegramConfig {
|
||||||
bot_token: "tg-bot-secret".into(),
|
bot_token: "tg-bot-secret".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec!["alice".into()],
|
allowed_users: vec!["alice".into()],
|
||||||
stream_mode: StreamMode::default(),
|
stream_mode: StreamMode::default(),
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ async fn config_secrets_create_master_key_in_keyring_on_fresh_install() {
|
|||||||
channels_config: openhuman_core::openhuman::config::schema::ChannelsConfig {
|
channels_config: openhuman_core::openhuman::config::schema::ChannelsConfig {
|
||||||
telegram: Some(TelegramConfig {
|
telegram: Some(TelegramConfig {
|
||||||
bot_token: "fresh-tg-secret".into(),
|
bot_token: "fresh-tg-secret".into(),
|
||||||
|
chat_id: None,
|
||||||
allowed_users: vec!["bob".into()],
|
allowed_users: vec!["bob".into()],
|
||||||
stream_mode: StreamMode::default(),
|
stream_mode: StreamMode::default(),
|
||||||
draft_update_interval_ms: 1000,
|
draft_update_interval_ms: 1000,
|
||||||
|
|||||||
@@ -252,6 +252,13 @@ impl Channel for CapturingChannel {
|
|||||||
"capture"
|
"capture"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// External channel exposes a proactive delivery target so the proactive
|
||||||
|
// router resolves a recipient for it (#3794 — recipient-less proactive sends
|
||||||
|
// are skipped for channels that return `None`).
|
||||||
|
fn proactive_target(&self) -> Option<String> {
|
||||||
|
Some("capture".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
async fn send(&self, message: &SendMessage) -> Result<()> {
|
async fn send(&self, message: &SendMessage) -> Result<()> {
|
||||||
self.sent
|
self.sent
|
||||||
.lock()
|
.lock()
|
||||||
@@ -2207,7 +2214,9 @@ async fn proactive_subscriber_routes_web_and_active_external_channel_without_net
|
|||||||
let sent = capture.sent.lock().expect("capture lock").clone();
|
let sent = capture.sent.lock().expect("capture lock").clone();
|
||||||
assert_eq!(sent.len(), 1);
|
assert_eq!(sent.len(), 1);
|
||||||
assert_eq!(sent[0].content, "send through active external channel");
|
assert_eq!(sent[0].content, "send through active external channel");
|
||||||
assert_eq!(sent[0].recipient, "");
|
// Recipient is now resolved from the channel's proactive_target (#3794),
|
||||||
|
// rather than the previously-empty recipient.
|
||||||
|
assert_eq!(sent[0].recipient, "capture");
|
||||||
|
|
||||||
subscriber.set_active_channel(Some("web".into()));
|
subscriber.set_active_channel(Some("web".into()));
|
||||||
subscriber
|
subscriber
|
||||||
|
|||||||
Reference in New Issue
Block a user