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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ links to the editor documented in
[Channel Runtime Editor](../2026-09-15-channel-runtime-editor.md); its edit form
now exposes only Label and Skill name, preserving existing service authorization.

Channel refresh is explicitly user driven. Do not add background polling or
refresh on focus/reconnection to these pages.
Channel browsing refresh is explicitly user driven, with no refresh on
focus/reconnection. The Telegram creation form alone automatically confirms
its accepted registration within a bounded window, as described below.

Manual Refresh keeps the connected table mounted and displays the shared
loading overlay over that table while the registration list, bot names, and
Expand Down Expand Up @@ -131,16 +132,24 @@ Registration uses existing `POST /api/channels/registrations` with
`platform`, `bot_token`, `label`, `default_skill_name`, `webhook_base_url`, and
the explicit selection above. A `202`/`status: accepted` response must contain
the registration ID. After admission, the form clears its token, retains only
the safe returned ID, and explicitly refreshes registrations once. A successful
the safe returned ID, and immediately refreshes registrations. A successful
fresh read must contain that exact ID, route scope and Telegram platform before
the success toast and navigation to the newly created channel's existing detail
page. The form does not return to the collection automatically or infer success
from cached rows after a failed read. Until confirmation, it preserves the
submitted choices and shows a pending state with Check again. This action only
repeats the GET, never the create POST; failed confirmation reads show a safe
error toast and remain retryable. No polling, window-focus refresh, or reconnect
refresh runs. Inbound activity continues to use its real status, independently
of registration creation.
from cached rows after a failed read. Nyx provisioning completes before admission,
but the local registration list becomes visible asynchronously; a single read
can miss a successfully created channel, including a token-only submission.
Until confirmation, the form preserves submitted choices and shows Connecting
in the existing submit button. There is no separate pending panel or Check again
button. An absent row or failed read automatically retries only the GET after
one second, without overlapping requests or repeating the POST. The entire
confirmation window, including stalled reads, is limited to 30 seconds.
Completion or unmount stops the timers and ignores late responses. On timeout,
the disabled submit button shows Connection pending, one warning explains the
delay, and Back to channels remains available. No success is claimed and the
accepted registration cannot be submitted again. Window focus and reconnection
do not start confirmation. Inbound activity continues to use its real status,
independently of registration creation.
Registration failures, including
504 and network errors, show a shared error toast and restore editable fields and Connect
Telegram. Inputs and still-authorized selections are preserved for an explicit manual
Expand Down
10 changes: 4 additions & 6 deletions apps/aevatar-console-web/src/locales/channelMessages.en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,11 @@ export default {
'Only selected services will be available to this bot.',
'channels.connect.selectionChanged':
'Some selected services are no longer available. Deselect them before connecting.',
'channels.connect.pending':
'Your request was submitted. Check again to open your channel when it is ready.',
'channels.connect.check': 'Check again',
'channels.connect.connecting': 'Connecting...',
'channels.connect.awaitingConfirmation': 'Connection pending',
'channels.connect.delayed':
'Your request was submitted, but confirmation is taking longer than expected. You can return to Channels.',
'channels.connect.success': 'Telegram channel created.',
'channels.connect.error.confirmation':
'Could not check your channel. Please check again.',
'channels.connect.submitted': 'Request submitted',
'channels.connect.error.token':
'Check the bot token from BotFather and try again.',
'channels.connect.error.botName':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,12 +133,11 @@ export default {
'channels.connect.selectionHelp': '此机器人只能使用选中的服务。',
'channels.connect.selectionChanged':
'部分选中服务已不可用,请取消勾选后再连接。',
'channels.connect.pending':
'请求已提交。可点击再次检查,准备完成后将打开你的渠道详情。',
'channels.connect.check': '再次检查',
'channels.connect.connecting': '连接中...',
'channels.connect.awaitingConfirmation': '连接待确认',
'channels.connect.delayed':
'请求已提交,但确认所需时间较长。你可以返回渠道列表。',
'channels.connect.success': 'Telegram 渠道已创建。',
'channels.connect.error.confirmation': '暂时无法查看渠道,请再次检查。',
'channels.connect.submitted': '请求已提交',
'channels.connect.error.token': '请检查 BotFather 提供的令牌后重试。',
'channels.connect.error.botName':
'暂时无法获取 Telegram 机器人名称,请重试。',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,27 @@ afterEach(() => {
global.fetch = originalFetch;
});

it('confirms the exact submitted channel with fresh reads and retries only GET without automatic refresh', async () => {
it('automatically confirms a token-only submission after delayed, failed and unrelated reads without repeating creation', async () => {
jest.useFakeTimers();
try {
let attempts = 0;
let reads = 0;
telegramFetch.mockResolvedValue(
response({
ok: true,
result: { is_bot: true, first_name: 'Token-only bot' },
}),
);
fetchMock.mockImplementation(async (input, init) => {
if (init?.method === 'POST') {
attempts += 1;
return attempts === 1
? response({}, 504)
: response(
{ status: 'accepted', registration_id: 'registration-new' },
202,
);
}
if (init?.method === 'POST')
return response(
{ status: 'accepted', registration_id: 'registration-new' },
202,
);
if (input === listPath) {
reads += 1;
if (reads === 1) return response({}, 503);
if (reads === 2)
if (reads === 2) return response([]);
if (reads === 3)
return response([
{
...createdRegistration,
Expand All @@ -113,6 +115,7 @@ it('confirms the exact submitted channel with fresh reads and retries only GET w
id: 'registration-new',
platform: 'lark',
},
{ ...createdRegistration, id: 'registration-new', owned: false },
createdRegistration,
]);
return response([{ ...createdRegistration, id: 'registration-new' }]);
Expand All @@ -138,48 +141,53 @@ it('confirms the exact submitted channel with fresh reads and retries only GET w
await screen.findByText(/No services are available/);
await idleAndReturn();
expect(fetchMock.mock.calls.map(([input]) => input)).toEqual([servicePath]);
enterBotDetails();
fireEvent.click(screen.getByRole('button', { name: 'Connect Telegram' }));
await screen.findByText(
'Could not confirm the connection. Please try again.',
);
await idleAndReturn();
expect(history.replace).not.toHaveBeenCalled();
expect(attempts).toBe(1);
fireEvent.change(screen.getByLabelText(/^Bot token/), {
target: { value: telegramToken },
});
fireEvent.click(screen.getByRole('button', { name: 'Connect Telegram' }));
await screen.findByText(
'Could not check your channel. Please check again.',
);
await act(async () => jest.advanceTimersByTimeAsync(1));
expect(reads).toBe(1);
expect(history.replace).not.toHaveBeenCalled();
expect(
screen.queryByText('Telegram channel created.'),
).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /Connecting/ })).toBeDisabled();
expect(
screen.getByRole('button', { name: 'Request submitted' }),
).toBeDisabled();
screen.queryByRole('button', { name: 'Check again' }),
).not.toBeInTheDocument();
expect(
screen.queryByText(/Your request was submitted/),
).not.toBeInTheDocument();
expect(screen.getByLabelText(/^Bot token/)).toHaveValue('');
const leaving = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(leaving);
expect(leaving.defaultPrevented).toBe(false);
fireEvent.submit(screen.getByRole('form', { name: 'Connect Telegram' }));
await idleAndReturn();
expect(history.replace).not.toHaveBeenCalled();
expect(attempts).toBe(2);
expect(reads).toBe(1);
fireEvent.click(screen.getByRole('button', { name: 'Check again' }));
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Check again' })).toBeEnabled(),
);
await idleAndReturn();
await act(async () => jest.advanceTimersByTimeAsync(1000));
expect(reads).toBe(2);
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(history.replace).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Check again' }));
await screen.findByText('Telegram channel created.');
await act(async () => jest.advanceTimersByTimeAsync(1000));
expect(reads).toBe(3);
expect(history.replace).not.toHaveBeenCalled();
await act(async () => jest.advanceTimersByTimeAsync(1000));
expect(screen.getByText('Telegram channel created.')).toBeInTheDocument();
expect(history.replace).toHaveBeenCalledWith(
'/scopes/scope-alpha/workflow-activity-vnext/channels/registration-new',
);
expect(attempts).toBe(2);
expect(reads).toBe(3);
const posts = fetchMock.mock.calls.filter(
([, init]) => init?.method === 'POST',
);
expect(posts).toHaveLength(1);
expect(JSON.parse(String(posts[0][1]?.body))).toMatchObject({
bot_token: telegramToken,
label: expect.stringMatching(/^Token-only bot-[1-9]\d{5}$/),
default_skill_name: 'Token-only bot',
service_ids: [],
});
await idleAndReturn();
expect(reads).toBe(4);
expect(history.replace).toHaveBeenCalledTimes(1);
view.unmount();
} finally {
focusManager.setFocused(undefined);
Expand All @@ -191,6 +199,105 @@ it('confirms the exact submitted channel with fresh reads and retries only GET w
}
});

it('bounds confirmation even when a read stalls and keeps the accepted form from being resubmitted', async () => {
jest.useFakeTimers();
try {
let resolveRead!: (value: Response) => void;
const pendingRead = new Promise<Response>((resolve) => {
resolveRead = resolve;
});
fetchMock.mockImplementation(async (input, init) => {
if (init?.method === 'POST')
return response(
{ status: 'accepted', registration_id: createdRegistration.id },
202,
);
if (input === listPath) return pendingRead;
return response({ services: [] });
});
const view = renderWithQueryClient(
<TelegramConnectionPage scopeId="scope-alpha" />,
);
await screen.findByText(/No services are available/);
enterBotDetails();
fireEvent.click(screen.getByRole('button', { name: 'Connect Telegram' }));
await act(async () => jest.advanceTimersByTimeAsync(1));
await act(async () => jest.advanceTimersByTimeAsync(30_000));
expect(
screen.getByRole('button', { name: 'Connection pending' }),
).toBeDisabled();
expect(screen.getByRole('alert')).toHaveTextContent(
'Your request was submitted, but confirmation is taking longer than expected.',
);
expect(
screen.queryByRole('button', { name: 'Check again' }),
).not.toBeInTheDocument();
fireEvent.submit(screen.getByRole('form', { name: 'Connect Telegram' }));
await act(async () => resolveRead(response([createdRegistration])));
await act(async () => jest.advanceTimersByTimeAsync(60_000));
expect(history.replace).not.toHaveBeenCalled();
expect(
screen.queryByText('Telegram channel created.'),
).not.toBeInTheDocument();
expect(
fetchMock.mock.calls.filter(([input]) => input === listPath),
).toHaveLength(2);
fireEvent.click(screen.getByRole('button', { name: 'Back to channels' }));
expect(history.push).toHaveBeenCalledWith(
'/scopes/scope-alpha/workflow-activity-vnext/channels',
);
view.unmount();
} finally {
jest.useRealTimers();
}
});

it.each([
'pending read',
'retry delay',
])('stops confirmation after leaving during a %s', async (phase) => {
jest.useFakeTimers();
try {
let resolveRead!: (value: Response) => void;
const pendingRead = new Promise<Response>((resolve) => {
resolveRead = resolve;
});
fetchMock.mockImplementation(async (input, init) => {
if (init?.method === 'POST')
return response(
{ status: 'accepted', registration_id: createdRegistration.id },
202,
);
if (input === listPath) return pendingRead;
return response({ services: [] });
});
const view = renderWithQueryClient(
<TelegramConnectionPage scopeId="scope-alpha" />,
);
await screen.findByText(/No services are available/);
enterBotDetails();
fireEvent.click(screen.getByRole('button', { name: 'Connect Telegram' }));
await act(async () => jest.advanceTimersByTimeAsync(1));
if (phase === 'retry delay')
await act(async () => resolveRead(response([])));
view.unmount();
await act(async () => resolveRead(response([createdRegistration])));
await act(async () => jest.advanceTimersByTimeAsync(60_000));
expect(history.replace).not.toHaveBeenCalled();
expect(
fetchMock.mock.calls.filter(([input]) => input === listPath),
).toHaveLength(2);
expect(
screen.queryByText('Telegram channel created.'),
).not.toBeInTheDocument();
expect(
screen.queryByText(/confirmation is taking longer/),
).not.toBeInTheDocument();
} finally {
jest.useRealTimers();
}
});

it('creates once and opens the new channel details with its saved name, skill and service grants', async () => {
jest.mocked(history.replace).mockRestore();
let resolvePost!: (value: Response) => void;
Expand Down Expand Up @@ -496,7 +603,9 @@ it('shows a name-lookup toast and retries with the current token and custom fiel
{ status: 'accepted', registration_id: 'registration-lookup' },
202,
);
return input === servicePath ? response({ services: [] }) : response([]);
return input === servicePath
? response({ services: [] })
: response([{ ...createdRegistration, id: 'registration-lookup' }]);
});
renderWithQueryClient(<TelegramConnectionPage scopeId="scope-alpha" />);
await screen.findByText(/No services are available/);
Expand Down Expand Up @@ -525,7 +634,7 @@ it('shows a name-lookup toast and retries with the current token and custom fiel
target: { value: 'custom-skill' },
});
fireEvent.click(screen.getByRole('button', { name: 'Connect Telegram' }));
await screen.findByText(/Your request was submitted/);
await screen.findByText('Telegram channel created.');
expect(telegramFetch.mock.calls.map(([url]) => url)).toEqual([
`https://api.telegram.org/bot${telegramToken}/getMe`,
]);
Expand Down
Loading
Loading