feat: add Telegram private messaging channel

Connect a user-created Telegram bot to channel projects with encrypted credentials, numeric sender allowlists, unified execution and text replies. Use Electron networking for system proxy support, with polling recovery, cancellable delivery retries and live status updates.

Add schema 61 channel support, lifecycle regression tests, Electron integration probes and feature documentation. Require the performance principles in AGENTS.md and record three alternating baseline/current Main benchmark rounds.

Validation: typecheck, lint and diff checks passed. Full suite: 6087 passed, 87 skipped, one intermittent inline-help Electron failure that passed unchanged in isolation. Real Bot plus model acceptance remains pending; the local TLS proxy probe is opt-in.

Release note: 新增 Telegram 私聊通道,可配置 Bot Token 和用户白名单,通过通道项目处理文字请求并回复结果。支持系统代理、断线重连和停用取消;真实 Bot 与模型的完整联调仍待完成。
This commit is contained in:
mesalogo
2026-10-05 02:42:28 +08:00
parent 78afec7775
commit cf598121b1
56 changed files with 4144 additions and 194 deletions
+12 -1
View File
@@ -9951,13 +9951,14 @@ describe("App", () => {
["weixin", "微信 ClawBot"],
["wecom", "企业微信"],
["dingtalk", "钉钉"],
["telegram", "Telegram"],
].map(([channel, name], index) => ({
...project,
id: `00000000-0000-4000-8000-00000000020${index + 1}`,
name: name!,
description: `${name}通道项目`,
kind: "channel" as const,
channel: channel as "weixin" | "wecom" | "dingtalk",
channel: channel as AssistantProject["channel"],
runtimeSelection: {
provider: "model" as const,
profileId: modelProfileId,
@@ -9991,7 +9992,17 @@ describe("App", () => {
},
);
api.channels = {
onStatusChanged: vi.fn(() => () => undefined),
getSnapshot: vi.fn(async () => ({
telegram: {
enabled: false,
secretConfigured: false,
source: "none" as const,
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false as const,
status: { state: "disabled" as const },
},
weixin: {
enabled: false,
bindingConfigured: false,
+1 -1
View File
@@ -764,7 +764,7 @@ function isConversation(value: unknown): value is Conversation {
(item.remote === undefined ||
(typeof item.remote === "object" &&
item.remote !== null &&
["weixin", "wecom", "dingtalk"].includes(
["weixin", "wecom", "dingtalk", "telegram"].includes(
String((item.remote as Record<string, unknown>).channel),
))) &&
typeof item.title === "string" &&
+4
View File
@@ -25,6 +25,10 @@ import type { ProjectChannel } from '../../shared/assistant-contracts'
* THE SOFTWARE.
*/
const channelMarks = {
telegram: {
viewBox: '0 0 24 24',
path: 'M22 2 2 10l7 3 3 9 10-20ZM5.6 10l12-4.8-8 6.1L5.6 10Zm5.2 3 7.4-6.2-6 11.9-1.4-5.7Z'
},
weixin: {
viewBox: '0 0 24 24',
path: 'M8.691 2.188C3.891 2.188 0 5.476 0 9.53c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.932-6.446 1.703-1.415 3.882-1.98 5.853-1.838-.576-3.583-4.196-6.348-8.596-6.348zM5.785 5.991c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178A1.17 1.17 0 0 1 4.623 7.17c0-.651.52-1.18 1.162-1.18zm5.813 0c.642 0 1.162.529 1.162 1.18a1.17 1.17 0 0 1-1.162 1.178 1.17 1.17 0 0 1-1.162-1.178c0-.651.52-1.18 1.162-1.18zm5.34 2.867c-1.797-.052-3.746.512-5.28 1.786-1.72 1.428-2.687 3.72-1.78 6.22.942 2.453 3.666 4.229 6.884 4.229.826 0 1.622-.12 2.361-.336a.722.722 0 0 1 .598.082l1.584.926a.272.272 0 0 0 .14.047c.134 0 .24-.111.24-.247 0-.06-.023-.12-.038-.177l-.327-1.233a.582.582 0 0 1-.023-.156.49.49 0 0 1 .201-.398C23.024 18.48 24 16.82 24 14.98c0-3.21-2.931-5.837-6.656-6.088V8.89c-.135-.01-.27-.027-.407-.03zm-2.53 3.274c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.97-.982zm4.844 0c.535 0 .969.44.969.982a.976.976 0 0 1-.969.983.976.976 0 0 1-.969-.983c0-.542.434-.982.969-.982z'
@@ -1,4 +1,5 @@
import {
act,
cleanup,
fireEvent,
render,
@@ -8,7 +9,7 @@ import {
} from '@testing-library/react'
import type { ComponentProps } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import type { ChannelRuntimeStatusChange, ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import {
defaultRuntimeSettings,
type DesktopApi,
@@ -71,6 +72,15 @@ const runtimeSettings: RuntimeSettings = {
}
const snapshot: ChannelSettingsSnapshot = {
telegram: {
enabled: false,
secretConfigured: false,
source: 'none',
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false,
status: { state: 'disabled' }
},
weixin: {
enabled: false,
bindingConfigured: false,
@@ -102,7 +112,8 @@ const snapshot: ChannelSettingsSnapshot = {
const projects: AssistantProject[] = [
['weixin', '微信 ClawBot'],
['wecom', '企业微信'],
['dingtalk', '钉钉']
['dingtalk', '钉钉'],
['telegram', 'Telegram']
].map(([channel, name], index) => ({
id: `00000000-0000-4000-8000-00000000000${index + 1}`,
name: name!,
@@ -114,7 +125,7 @@ const projects: AssistantProject[] = [
},
runtimeSelection: { provider: 'model' },
kind: 'channel',
channel: channel as 'weixin' | 'wecom' | 'dingtalk',
channel: channel as AssistantProject['channel'],
status: 'active',
createdAt: '2026-08-04T00:00:00.000Z',
updatedAt: '2026-08-04T00:00:00.000Z'
@@ -167,6 +178,223 @@ afterEach(async () => {
})
describe('ChannelSettingsSection', () => {
it('keeps pushed channel status newer than loading and updates reconnect errors without resetting drafts', async () => {
let receive!: (change: ChannelRuntimeStatusChange) => void
const unsubscribe = vi.fn()
const onStatusChanged = vi.fn((listener: typeof receive) => {
receive = listener
return unsubscribe
})
const getSnapshot = vi.fn(async () => {
receive({ channel: 'telegram', status: { state: 'running' } })
return { ...snapshot, telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, status: { state: 'starting' } } }
})
const apply = vi.fn()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: { ...bindingApi(), onStatusChanged, getSnapshot, apply, testConnection: vi.fn() },
settings: settingsApi(), projects: { update: vi.fn() }
} as unknown as DesktopApi
})
const { unmount } = renderChannelSettings({ initialChannel: 'telegram' })
const token = await screen.findByLabelText('TelegramBot Token')
expect(screen.getByText('已连接')).toBeInTheDocument()
expect(screen.queryByText('正在连接')).not.toBeInTheDocument()
fireEvent.change(token, { target: { value: 'unsaved-token-placeholder' } })
const senders = screen.getByLabelText('Telegram允许的发送者 ID')
fireEvent.change(senders, { target: { value: '12345\n67890' } })
act(() => receive({ channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } }))
expect(screen.getByText('连接失败')).toBeInTheDocument()
expect(screen.getByRole('alert')).toHaveTextContent('Network unavailable')
act(() => receive({ channel: 'telegram', status: { state: 'starting' } }))
expect(screen.getByText('正在连接')).toBeInTheDocument()
expect(screen.queryByText('Network unavailable')).not.toBeInTheDocument()
act(() => receive({ channel: 'telegram', status: { state: 'running' } }))
expect(screen.getByText('已连接')).toBeInTheDocument()
expect(token).toHaveValue('unsaved-token-placeholder')
expect(senders).toHaveValue('12345\n67890')
expect(getSnapshot).toHaveBeenCalledOnce()
expect(onStatusChanged).toHaveBeenCalledOnce()
expect(apply).not.toHaveBeenCalled()
unmount()
expect(unsubscribe).toHaveBeenCalledOnce()
})
it('tests and saves Telegram credentials, keeps them on whitelist edits, and clears them explicitly', async () => {
const configured = {
...snapshot,
telegram: { ...snapshot.telegram, enabled: true, secretConfigured: true, source: 'encrypted' as const }
}
const apply = vi.fn()
.mockResolvedValueOnce(configured)
.mockResolvedValueOnce({ ...configured, telegram: { ...configured.telegram, allowedSenderIds: ['12345', '67890'] } })
.mockResolvedValueOnce(snapshot)
const testConnection = vi.fn(async () => ({ channel: 'telegram', ok: true, botUsername: 'goodbuddy_test_bot' }))
const updateProject = vi.fn(async (id: string, input: ProjectCreateInput) => ({ ...projects.find((p) => p.id === id)!, ...input }))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: { ...bindingApi(), getSnapshot: vi.fn(async () => snapshot), apply, testConnection },
settings: settingsApi(),
projects: { update: updateProject }
} as unknown as DesktopApi
})
const onNotify = vi.fn()
renderChannelSettings({ initialChannel: 'telegram', onNotify })
const token = await screen.findByLabelText('TelegramBot Token')
expect(token).toHaveAttribute('type', 'password')
expect(screen.getByRole('link', { name: '打开官方 BotFather' })).toHaveAttribute('href', 'https://t.me/BotFather')
expect(screen.getByText(/尚未授权用户/u)).toBeInTheDocument()
expect(screen.queryByRole('switch', { name: /群聊/u })).not.toBeInTheDocument()
expect(screen.queryByLabelText(/机器人 ID/u)).not.toBeInTheDocument()
expect(screen.queryByRole('group', { name: /默认模式/u })).not.toBeInTheDocument()
fireEvent.change(token, { target: { value: 'test-token-placeholder' } })
fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
await waitFor(() => {
expect(testConnection).toHaveBeenCalledWith('telegram', {
enabled: false, secret: { action: 'replace', value: 'test-token-placeholder' }, allowedSenderIds: [], allowGroupMessages: false
})
expect(screen.getByRole('link', { name: '@goodbuddy_test_bot' })).toHaveAttribute('href', 'https://t.me/goodbuddy_test_bot')
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
})
expect(apply).not.toHaveBeenCalled()
expect(screen.getByRole('switch', { name: '启用Telegram通道' })).not.toBeChecked()
expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ message: 'Telegram 凭据验证成功;测试不会启用消息接收。' }))
fireEvent.click(screen.getByRole('switch', { name: '启用Telegram通道' }))
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
await waitFor(() => {
expect(apply).toHaveBeenLastCalledWith({ telegram: {
enabled: true, secret: { action: 'replace', value: 'test-token-placeholder' }, allowedSenderIds: [], allowGroupMessages: false
} })
expect(token).toHaveValue('')
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
})
fireEvent.change(screen.getByLabelText('Telegram允许的发送者 ID'), { target: { value: '12345,67890\n12345' } })
fireEvent.change(screen.getByLabelText('Telegram 默认工作目录'), { target: { value: 'C:\\TelegramWorkspace' } })
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
await waitFor(() => {
expect(apply).toHaveBeenLastCalledWith({ telegram: {
enabled: true, secret: { action: 'keep' }, allowedSenderIds: ['12345', '67890'], allowGroupMessages: false
} })
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
})
expect(updateProject).toHaveBeenCalledWith(projects[3]!.id, expect.objectContaining({ rootPath: 'C:\\TelegramWorkspace' }))
fireEvent.click(screen.getByRole('checkbox', { name: /保存时清除 Token/u }))
expect(screen.queryByRole('link', { name: '@goodbuddy_test_bot' })).not.toBeInTheDocument()
expect(token).toBeDisabled()
expect(screen.getByRole('button', { name: '测试Telegram连接' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
await waitFor(() => {
expect(apply).toHaveBeenLastCalledWith({ telegram: {
enabled: false, secret: { action: 'clear' }, allowedSenderIds: ['12345', '67890'], allowGroupMessages: false
} })
expect(screen.queryByRole('checkbox', { name: /保存时清除 Token/u })).not.toBeInTheDocument()
expect(token).toHaveValue('')
expect(token).toBeEnabled()
expect(screen.getByRole('switch', { name: '启用Telegram通道' })).not.toBeChecked()
})
})
it('validates Telegram IDs and retains the draft after test and save failures', async () => {
const apply = vi.fn().mockRejectedValue(new Error('Save failed'))
const testConnection = vi.fn().mockResolvedValue({ channel: 'telegram', ok: false, error: 'Webhook in use' })
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: { ...bindingApi(), getSnapshot: vi.fn(async () => snapshot), apply, testConnection },
settings: settingsApi(), projects: { update: vi.fn(async () => projects[3]) }
} as unknown as DesktopApi
})
renderChannelSettings({ initialChannel: 'telegram' })
const token = await screen.findByLabelText('TelegramBot Token')
fireEvent.change(token, { target: { value: 'test-token-placeholder' } })
const senders = screen.getByLabelText('Telegram允许的发送者 ID')
fireEvent.change(senders, { target: { value: '@username' } })
expect(senders).toHaveAttribute('aria-invalid', 'true')
expect(senders).toHaveAccessibleDescription('请填写正整数用户 ID,最多 100 个,每个不超过 256 位,不要填写 @用户名。')
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
expect(apply).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: '测试Telegram连接' })).toBeDisabled()
fireEvent.change(senders, { target: { value: '12345' } })
fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
await waitFor(() => {
expect(screen.getByText('Webhook in use')).toBeInTheDocument()
expect(token).toBeEnabled()
expect(token).toHaveValue('test-token-placeholder')
})
fireEvent.click(screen.getByRole('button', { name: '保存通道设置' }))
await waitFor(() => {
expect(screen.getByText('Save failed')).toBeInTheDocument()
expect(token).toHaveValue('test-token-placeholder')
expect(senders).toHaveValue('12345')
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeEnabled()
})
})
it('tests saved Telegram Tokens without replacement and locks edits until the result arrives', async () => {
let resolveTest!: (value: { channel: 'telegram'; ok: boolean; botUsername: string }) => void
const testConnection = vi.fn(() => new Promise((resolve) => { resolveTest = resolve }))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
...bindingApi(),
getSnapshot: vi.fn(async () => ({ ...snapshot, telegram: { ...snapshot.telegram, secretConfigured: true, source: 'encrypted' } })),
apply: vi.fn(), testConnection
},
settings: settingsApi(), projects: { update: vi.fn() }
} as unknown as DesktopApi
})
renderChannelSettings({ initialChannel: 'telegram' })
const token = await screen.findByLabelText('TelegramBot Token')
fireEvent.click(screen.getByRole('button', { name: '测试Telegram连接' }))
expect(testConnection).toHaveBeenCalledWith('telegram', {
enabled: false, secret: { action: 'keep' }, allowedSenderIds: [], allowGroupMessages: false
})
expect(token).toBeDisabled()
expect(screen.getByRole('checkbox', { name: /保存时清除 Token/u })).toBeDisabled()
expect(screen.getByRole('button', { name: '保存通道设置' })).toBeDisabled()
await act(async () => resolveTest({ channel: 'telegram', ok: true, botUsername: 'saved_test_bot' }))
await waitFor(() => {
expect(token).toBeEnabled()
expect(screen.getByRole('link', { name: '@saved_test_bot' })).toBeInTheDocument()
})
fireEvent.change(token, { target: { value: 'replacement-placeholder' } })
expect(screen.queryByRole('link', { name: '@saved_test_bot' })).not.toBeInTheDocument()
})
it('renders English Telegram setup and tests read-only environment credentials', async () => {
const testConnection = vi.fn(async () => ({ channel: 'telegram', ok: true }))
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
channels: {
...bindingApi(),
getSnapshot: vi.fn(async () => ({ ...snapshot, telegram: { ...snapshot.telegram, secretConfigured: true, readOnly: true, source: 'environment' } })),
apply: vi.fn(), testConnection
},
settings: settingsApi(), projects: { update: vi.fn() }
} as unknown as DesktopApi
})
await i18n.changeLanguage('en-US')
renderChannelSettings({ initialChannel: 'telegram' })
expect(await screen.findByLabelText('Telegram Bot Token')).toBeDisabled()
expect(screen.getByRole('switch', { name: 'Enable the Telegram channel' })).toBeDisabled()
expect(screen.getByLabelText('Telegram allowed sender IDs')).toBeDisabled()
expect(screen.getByText(/Send \/newbot to BotFather/u)).toHaveTextContent('/whoami')
expect(screen.getByText(/No users authorized/u)).toBeInTheDocument()
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Connection and offline messages' }))
expect(await screen.findByRole('tooltip')).toHaveTextContent('operating system proxy')
fireEvent.click(screen.getByRole('button', { name: 'Test Telegram connection' }))
await waitFor(() => {
expect(testConnection).toHaveBeenCalledWith('telegram', undefined)
expect(screen.getByRole('button', { name: 'Test Telegram connection' })).toBeEnabled()
})
expect(screen.queryByRole('link', { name: /^@/u })).not.toBeInTheDocument()
})
it('saves editable channel settings without returning stored secrets', async () => {
const updateProject = vi.fn(async (
projectId: string,
@@ -545,7 +773,7 @@ describe('ChannelSettingsSection', () => {
expect(apply).not.toHaveBeenCalled()
})
it('presents the three channel configurations as keyboard tabs', async () => {
it('presents the four channel configurations as keyboard tabs', async () => {
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
@@ -581,6 +809,7 @@ describe('ChannelSettingsSection', () => {
expect(weixinTab).toHaveAttribute('aria-selected', 'true')
expect(wecomTab).toHaveAttribute('tabindex', '-1')
expect(dingtalkTab).toHaveAttribute('tabindex', '-1')
expect(within(tablist).getByRole('tab', { name: 'Telegram' })).toHaveAttribute('tabindex', '-1')
expect(screen.getByText('项目设置')).toBeInTheDocument()
expect(
screen.getByText('与左上角当前通道项目的设置保持同步。')
@@ -601,6 +830,9 @@ describe('ChannelSettingsSection', () => {
expect(
screen.getByRole('switch', { name: '启用企业微信通道' })
).toBeInTheDocument()
fireEvent.keyDown(wecomTab, { key: 'End' })
expect(within(tablist).getByRole('tab', { name: 'Telegram' })).toHaveFocus()
expect(screen.getByRole('tabpanel')).toHaveAttribute('aria-labelledby', 'channel-settings-tab-telegram')
})
it('opens the requested channel configuration', async () => {
+154 -50
View File
@@ -4,17 +4,20 @@ import {
Smartphone,
Unplug
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { InlineHelp } from './InlineHelp'
import { createPortal } from 'react-dom'
import QRCode from 'qrcode'
import { telegramAllowedSenderIdsSchema } from '../../shared/channel-settings-contracts'
import type {
ChannelConnectionTestResult,
ChannelRuntimeStatus,
ChannelSettingsApply,
ChannelSettingsSnapshot,
CredentialChannel,
DingTalkChannelSettingsInput,
TelegramChannelSettingsInput,
WeComChannelSettingsInput
} from '../../shared/channel-settings-contracts'
import type { RuntimeSettings } from '../../shared/contracts'
@@ -26,6 +29,8 @@ import {
} from '../../shared/assistant-contracts'
import type { WeixinBindingSnapshot } from '../../shared/weixin-channel-contracts'
import type { AppNotificationInput } from './notifications'
import { createChannelStatusStore, type ChannelStatusStore } from './channel-status-store'
import { useChannelStatus } from './channel-status-selectors'
import { trapTabFocus } from './dialog-focus'
import { PageTabs } from './WorkspacePrimitives'
import { ChannelIcon } from './ChannelIcon'
@@ -96,7 +101,7 @@ function draftFromSnapshot(
identifier:
channel === 'wecom'
? snapshot.wecom.botId
: snapshot.dingtalk.clientId,
: channel === 'dingtalk' ? snapshot.dingtalk.clientId : '',
secret: '',
clearSecret: false,
allowedSenderIdsText: settings.allowedSenderIds.join('\n'),
@@ -112,17 +117,23 @@ function inputFor(
channel: 'dingtalk',
draft: ChannelDraft
): DingTalkChannelSettingsInput
function inputFor(
channel: 'telegram',
draft: ChannelDraft
): TelegramChannelSettingsInput
function inputFor(
channel: CredentialChannel,
draft: ChannelDraft
): WeComChannelSettingsInput | DingTalkChannelSettingsInput {
): WeComChannelSettingsInput | DingTalkChannelSettingsInput | TelegramChannelSettingsInput {
const common = {
enabled: draft.enabled,
secret: secretUpdate(draft),
allowedSenderIds: allowedSenderIds(draft.allowedSenderIdsText),
allowGroupMessages: draft.allowGroupMessages
}
return channel === 'wecom'
return channel === 'telegram'
? { ...common, enabled: draft.enabled && !draft.clearSecret, allowGroupMessages: false }
: channel === 'wecom'
? { ...common, botId: draft.identifier.trim() }
: { ...common, clientId: draft.identifier.trim() }
}
@@ -139,7 +150,7 @@ function channelDraftChanged(
const currentIdentifier =
channel === 'wecom'
? snapshot.wecom.botId
: snapshot.dingtalk.clientId
: channel === 'dingtalk' ? snapshot.dingtalk.clientId : ''
return (
draft.enabled !== current.enabled ||
draft.identifier.trim() !== currentIdentifier ||
@@ -222,7 +233,33 @@ function ChannelProjectCard({
)
}
const ChannelStatus = memo(function ChannelStatus({
channel,
fallback,
store,
unconfigured = false,
errorOnly = false
}: {
channel: ProjectChannel
fallback: ChannelRuntimeStatus
store: ChannelStatusStore
unconfigured?: boolean
errorOnly?: boolean
}): React.JSX.Element | null {
const { t } = useTranslation('integrations')
const status = useChannelStatus(store, channel, fallback)
if (errorOnly) {
return status.lastError
? <p className="settings-warning" role="alert">{status.lastError}</p>
: null
}
return <span>{unconfigured && (status.state === 'disabled' || status.state === 'stopped')
? t('channels.telegram.unconfigured')
: t(`channels.status.${status.state}`)}</span>
})
function ChannelEditor({
busy,
channel,
draft,
onChange,
@@ -232,8 +269,11 @@ function ChannelEditor({
project,
runtimeSettings,
settings,
testing
statusStore,
testing,
botUsername
}: {
busy: boolean
channel: CredentialChannel
draft: ChannelDraft
onChange: (next: ChannelDraft) => void
@@ -243,15 +283,16 @@ function ChannelEditor({
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
settings: ChannelSettingsSnapshot[CredentialChannel]
statusStore: ChannelStatusStore
testing: boolean
botUsername?: string
}): React.JSX.Element {
const { t } = useTranslation('integrations')
const title = t(`channels.tabs.${channel}`)
const identifierLabel = t(
`channels.credential.identifiers.${channel}`
)
const identifierLabel = channel === 'telegram' ? '' : t(`channels.credential.identifiers.${channel}`)
const secretLabel = t(`channels.credential.secrets.${channel}`)
const prefix = `channel-${channel}`
const invalidSenders = channel === 'telegram' && !telegramAllowedSenderIdsSchema.safeParse(allowedSenderIds(draft.allowedSenderIdsText)).success
return (
<>
@@ -269,7 +310,8 @@ function ChannelEditor({
: t('channels.credential.secretMissing')}
</small>
</div>
<span>{t(`channels.status.${settings.status.state}`)}</span>
<ChannelStatus channel={channel} fallback={settings.status} store={statusStore}
unconfigured={channel === 'telegram' && !settings.secretConfigured} />
</div>
{settings.readOnly && (
@@ -277,16 +319,12 @@ function ChannelEditor({
{t('channels.credential.readOnly')}
</p>
)}
{settings.status.lastError && (
<p className="settings-warning" role="alert">
{settings.status.lastError}
</p>
)}
<ChannelStatus channel={channel} fallback={settings.status} store={statusStore} errorOnly />
<label className="toggle-row" htmlFor={`${prefix}-enabled`}>
<input
checked={draft.enabled}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
id={`${prefix}-enabled`}
onChange={(event) =>
onChange({ ...draft, enabled: event.target.checked })
@@ -297,21 +335,39 @@ function ChannelEditor({
<span>{t('channels.credential.enable', { channel: title })}</span>
</label>
<label className="field">
{channel === 'telegram' && (
<>
<p><a href="https://t.me/BotFather" target="_blank" rel="noreferrer">{t('channels.telegram.botFather')}</a></p>
<p>{t('channels.telegram.setup')}</p>
<p className="settings-notice">{t('channels.telegram.access')}</p>
<span className="inline-help-label">
{t('channels.telegram.connectionHelp')}
<InlineHelp label={t('channels.telegram.connectionHelp')}>
{t('channels.telegram.network')} {t('channels.telegram.offline')}
</InlineHelp>
</span>
{allowedSenderIds(draft.allowedSenderIdsText).length === 0 && (
<p className="settings-notice">{t('channels.telegram.noUsers')}</p>
)}
{botUsername && <p>{t('channels.telegram.bot')} <a href={`https://t.me/${botUsername}`} target="_blank" rel="noreferrer">@{botUsername}</a></p>}
</>
)}
{channel !== 'telegram' && <label className="field">
<span>{identifierLabel}</span>
<input
aria-label={t('channels.credential.fieldAriaLabel', {
channel: title,
field: identifierLabel
})}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
maxLength={256}
onChange={(event) =>
onChange({ ...draft, identifier: event.target.value })
}
value={draft.identifier}
/>
</label>
</label>}
<label className="field">
<span>{secretLabel}</span>
@@ -321,15 +377,15 @@ function ChannelEditor({
field: secretLabel
})}
autoComplete="off"
disabled={settings.readOnly || draft.clearSecret}
disabled={settings.readOnly || busy || draft.clearSecret}
maxLength={4_096}
onChange={(event) =>
onChange({ ...draft, secret: event.target.value })
}
placeholder={
settings.secretConfigured
? t('channels.credential.keepSecret')
: t('channels.credential.enterSecret')
? t(channel === 'telegram' ? 'channels.telegram.keepToken' : 'channels.credential.keepSecret')
: t(channel === 'telegram' ? 'channels.telegram.enterToken' : 'channels.credential.enterSecret')
}
type="password"
value={draft.secret}
@@ -340,6 +396,7 @@ function ChannelEditor({
<label className="check-field">
<input
checked={draft.clearSecret}
disabled={busy}
onChange={(event) =>
onChange({
...draft,
@@ -349,18 +406,20 @@ function ChannelEditor({
}
type="checkbox"
/>
<span>{t('channels.credential.clearSecret')}</span>
<span>{t(channel === 'telegram' ? 'channels.telegram.clearToken' : 'channels.credential.clearSecret')}</span>
</label>
)}
<label className="field">
<span>{t('channels.credential.allowedSenders')}</span>
<textarea
aria-invalid={invalidSenders || undefined}
aria-describedby={invalidSenders ? `${prefix}-senders-error` : undefined}
aria-label={t(
'channels.credential.allowedSendersAriaLabel',
{ channel: title }
)}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
onChange={(event) =>
onChange({
...draft,
@@ -374,14 +433,15 @@ function ChannelEditor({
value={draft.allowedSenderIdsText}
/>
<small>
{t('channels.credential.allowedSendersHelp')}
{t(channel === 'telegram' ? 'channels.telegram.sendersHelp' : 'channels.credential.allowedSendersHelp')}
</small>
{invalidSenders && <small className="field-error" id={`${prefix}-senders-error`} role="alert">{t('channels.telegram.invalidSenders')}</small>}
</label>
<label className="toggle-row">
{channel !== 'telegram' && <label className="toggle-row">
<input
checked={draft.allowGroupMessages}
disabled={settings.readOnly}
disabled={settings.readOnly || busy}
onChange={(event) =>
onChange({
...draft,
@@ -392,11 +452,11 @@ function ChannelEditor({
type="checkbox"
/>
<span>{t('channels.credential.groupMessages')}</span>
</label>
</label>}
<button
className="secondary-button"
disabled={testing}
disabled={busy || draft.clearSecret || invalidSenders}
onClick={onTest}
type="button"
>
@@ -658,7 +718,8 @@ function WeixinChannelEditor({
onVerify,
project,
runtimeSettings,
settings
settings,
statusStore
}: {
binding: WeixinBindingSnapshot
bindingButtonRef: React.RefObject<HTMLButtonElement | null>
@@ -676,6 +737,7 @@ function WeixinChannelEditor({
project: ChannelProjectDraft
runtimeSettings: RuntimeSettings
settings: ChannelSettingsSnapshot['weixin']
statusStore: ChannelStatusStore
}): React.JSX.Element {
const { t } = useTranslation('integrations')
return (
@@ -697,14 +759,10 @@ function WeixinChannelEditor({
: t('channels.weixin.unbound')}
</small>
</div>
<span>{t(`channels.status.${settings.status.state}`)}</span>
<ChannelStatus channel="weixin" fallback={settings.status} store={statusStore} />
</div>
{settings.status.lastError && (
<p className="settings-warning" role="alert">
{settings.status.lastError}
</p>
)}
<ChannelStatus channel="weixin" fallback={settings.status} store={statusStore} errorOnly />
<label className="toggle-row" htmlFor="channel-weixin-enabled">
<input
@@ -799,9 +857,11 @@ export function ChannelSettingsSection({
const channelTabs = [
{ id: 'weixin', label: t('channels.tabs.weixin'), icon: <ChannelIcon channel="weixin" /> },
{ id: 'wecom', label: t('channels.tabs.wecom'), icon: <ChannelIcon channel="wecom" /> },
{ id: 'dingtalk', label: t('channels.tabs.dingtalk'), icon: <ChannelIcon channel="dingtalk" /> }
{ id: 'dingtalk', label: t('channels.tabs.dingtalk'), icon: <ChannelIcon channel="dingtalk" /> },
{ id: 'telegram', label: t('channels.tabs.telegram'), icon: <ChannelIcon channel="telegram" /> }
] as const
const [snapshot, setSnapshot] = useState<ChannelSettingsSnapshot>()
const [statusStore] = useState(createChannelStatusStore)
const [runtimeSettings, setRuntimeSettings] =
useState<RuntimeSettings>()
const [projectOverrides, setProjectOverrides] = useState<
@@ -819,10 +879,12 @@ export function ChannelSettingsSection({
Record<CredentialChannel, ChannelDraft>
>({
wecom: { ...emptyDraft },
dingtalk: { ...emptyDraft }
dingtalk: { ...emptyDraft },
telegram: { ...emptyDraft }
})
const [busy, setBusy] = useState(false)
const [testing, setTesting] = useState<CredentialChannel>()
const [telegramBotUsername, setTelegramBotUsername] = useState<string>()
const [error, setError] = useState<string>()
const bindingButtonRef = useRef<HTMLButtonElement>(null)
@@ -831,17 +893,20 @@ export function ChannelSettingsSection({
setBindingOpen(false)
}, [])
const applySnapshot = (next: ChannelSettingsSnapshot): void => {
const applySnapshot = useCallback((next: ChannelSettingsSnapshot): void => {
statusStore.seed(next)
setSnapshot(next)
setWeixinEnabled(next.weixin.enabled)
setDrafts({
wecom: draftFromSnapshot('wecom', next),
dingtalk: draftFromSnapshot('dingtalk', next)
dingtalk: draftFromSnapshot('dingtalk', next),
telegram: draftFromSnapshot('telegram', next)
})
}
}, [statusStore])
useEffect(() => {
const api = window.goodbuddy.channels
const disconnectStatus = statusStore.connect(api)
let active = true
void (async () => {
if (!api) {
@@ -882,14 +947,15 @@ export function ChannelSettingsSection({
)
return () => {
active = false
disconnectStatus()
removeBindingListener?.()
}
}, [closeBinding])
}, [applySnapshot, closeBinding, statusStore])
const persistedProjects = runtimeSettings
const persistedProjects = useMemo(() => runtimeSettings
? projectDraftsFrom(projectList, runtimeSettings)
: {}
const projects = Object.fromEntries(
: {}, [projectList, runtimeSettings])
const projects = useMemo(() => Object.fromEntries(
channelOrder.flatMap((channel) => {
const persisted = persistedProjects[channel]
if (!persisted) {
@@ -905,13 +971,17 @@ export function ChannelSettingsSection({
]
]
})
) as Partial<Record<ProjectChannel, ChannelProjectDraft>>
) as Partial<Record<ProjectChannel, ChannelProjectDraft>>, [persistedProjects, projectOverrides])
const save = async (): Promise<void> => {
const api = window.goodbuddy.channels
if (!api || !snapshot || !runtimeSettings) {
return
}
if (!snapshot.telegram.readOnly && !telegramAllowedSenderIdsSchema.safeParse(allowedSenderIds(drafts.telegram.allowedSenderIdsText)).success) {
setActiveChannel('telegram')
return
}
const channelProjects = channelOrder.map(
(channel) => projects[channel]
)
@@ -943,6 +1013,10 @@ export function ChannelSettingsSection({
...(!snapshot.dingtalk.readOnly &&
channelDraftChanged('dingtalk', drafts.dingtalk, snapshot)
? { dingtalk: inputFor('dingtalk', drafts.dingtalk) }
: {}),
...(!snapshot.telegram.readOnly &&
channelDraftChanged('telegram', drafts.telegram, snapshot)
? { telegram: inputFor('telegram', drafts.telegram) }
: {})
}
setBusy(true)
@@ -1096,21 +1170,25 @@ export function ChannelSettingsSection({
return
}
setTesting(channel)
if (channel === 'telegram') setTelegramBotUsername(undefined)
setError(undefined)
try {
const settings = snapshot[channel].readOnly
? undefined
: channel === 'wecom'
? inputFor('wecom', drafts.wecom)
: inputFor('dingtalk', drafts.dingtalk)
: channel === 'dingtalk'
? inputFor('dingtalk', drafts.dingtalk)
: inputFor('telegram', drafts.telegram)
const result: ChannelConnectionTestResult =
await api.testConnection(channel, settings)
if (!result.ok) {
throw new Error(result.error)
}
if (channel === 'telegram') setTelegramBotUsername(result.botUsername)
onNotify({
tone: 'success',
message: t('channels.connectionSuccess', {
message: t(channel === 'telegram' ? 'channels.telegram.verified' : 'channels.connectionSuccess', {
channel: t(`channels.tabs.${channel}`)
}),
dedupeKey: `channel-test-${channel}`
@@ -1127,12 +1205,14 @@ export function ChannelSettingsSection({
const weixinProject = projects.weixin
const wecomProject = projects.wecom
const dingtalkProject = projects.dingtalk
const telegramProject = projects.telegram
if (
!snapshot ||
!runtimeSettings ||
!weixinProject ||
!wecomProject ||
!dingtalkProject
!dingtalkProject ||
!telegramProject
) {
return (
<>
@@ -1156,7 +1236,7 @@ export function ChannelSettingsSection({
actions={
<button
className="primary-button"
disabled={busy}
disabled={busy || Boolean(testing)}
onClick={() => void save()}
type="button"
>
@@ -1212,9 +1292,11 @@ export function ChannelSettingsSection({
project={weixinProject}
runtimeSettings={runtimeSettings}
settings={snapshot.weixin}
statusStore={statusStore}
/>
) : activeChannel === 'wecom' ? (
<ChannelEditor
busy={busy || Boolean(testing)}
channel="wecom"
draft={drafts.wecom}
onChange={(next) =>
@@ -1226,10 +1308,31 @@ export function ChannelSettingsSection({
project={wecomProject}
runtimeSettings={runtimeSettings}
settings={snapshot.wecom}
statusStore={statusStore}
testing={testing === 'wecom'}
/>
) : activeChannel === 'telegram' ? (
<ChannelEditor
busy={busy || Boolean(testing)}
channel="telegram"
draft={drafts.telegram}
botUsername={telegramBotUsername}
onChange={(next) => {
if (next.secret !== drafts.telegram.secret || next.clearSecret !== drafts.telegram.clearSecret) setTelegramBotUsername(undefined)
setDrafts((current) => ({ ...current, telegram: next }))
}}
onProjectChange={(next) => updateProject('telegram', next)}
onSelectRoot={() => void selectRoot('telegram')}
onTest={() => void test('telegram')}
project={telegramProject}
runtimeSettings={runtimeSettings}
settings={snapshot.telegram}
statusStore={statusStore}
testing={testing === 'telegram'}
/>
) : (
<ChannelEditor
busy={busy || Boolean(testing)}
channel="dingtalk"
draft={drafts.dingtalk}
onChange={(next) =>
@@ -1243,6 +1346,7 @@ export function ChannelSettingsSection({
project={dingtalkProject}
runtimeSettings={runtimeSettings}
settings={snapshot.dingtalk}
statusStore={statusStore}
testing={testing === 'dingtalk'}
/>
)}
@@ -0,0 +1,11 @@
import { useSyncExternalStore } from 'react'
import type { ChannelRuntimeStatus, ManagedChannel } from '../../shared/channel-settings-contracts'
import type { ChannelStatusStore } from './channel-status-store'
export function useChannelStatus(
store: ChannelStatusStore,
channel: ManagedChannel,
fallback: ChannelRuntimeStatus
): ChannelRuntimeStatus {
return useSyncExternalStore(store.subscribe, () => store.getState()[channel] ?? fallback)
}
@@ -0,0 +1,60 @@
import { describe, expect, it, vi } from 'vitest'
import type { ChannelRuntimeStatusChange, ChannelSettingsSnapshot } from '../../shared/channel-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { createChannelStatusStore } from './channel-status-store'
const credential = {
enabled: true,
secretConfigured: true,
source: 'encrypted' as const,
readOnly: false,
allowedSenderIds: [],
allowGroupMessages: false as const,
status: { state: 'starting' as const }
}
const snapshot: ChannelSettingsSnapshot = {
weixin: { enabled: false, bindingConfigured: false, source: 'none', status: { state: 'disabled' } },
wecom: { ...credential, botId: 'test-bot' },
dingtalk: { ...credential, clientId: 'test-client' },
telegram: credential
}
describe('channel status store', () => {
it('keeps pushed status over a late snapshot, preserves other channel references, and deduplicates events', () => {
const store = createChannelStatusStore()
let receive!: (change: ChannelRuntimeStatusChange) => void
const unsubscribe = vi.fn()
const api = { onStatusChanged: vi.fn((listener: typeof receive) => { receive = listener; return unsubscribe }) } as unknown as DesktopApi['channels']
const disconnect = store.connect(api)
receive({ channel: 'telegram', status: { state: 'running' } })
store.seed(snapshot)
expect(store.getState().telegram).toEqual({ state: 'running' })
expect(store.getState().wecom).toEqual({ state: 'starting' })
const wecom = store.getState().wecom
const listener = vi.fn()
const removeListener = store.subscribe(listener)
receive({ channel: 'telegram', status: { state: 'running' } })
expect(listener).not.toHaveBeenCalled()
receive({ channel: 'telegram', status: { state: 'error', lastError: 'Network unavailable' } })
expect(listener).toHaveBeenCalledOnce()
expect(store.getState().wecom).toBe(wecom)
receive({ channel: 'telegram', status: { state: 'running' } })
expect(store.getState().telegram).toEqual({ state: 'running' })
removeListener()
disconnect()
expect(unsubscribe).toHaveBeenCalledOnce()
receive({ channel: 'telegram', status: { state: 'error', lastError: 'Late callback' } })
expect(store.getState().telegram).toEqual({ state: 'running' })
expect(listener).toHaveBeenCalledTimes(2)
})
it('uses updated snapshot baselines when the API has no status subscription', () => {
const store = createChannelStatusStore()
const disconnect = store.connect({} as DesktopApi['channels'])
store.seed(snapshot)
expect(store.getState().telegram).toEqual({ state: 'starting' })
store.seed({ ...snapshot, telegram: { ...credential, status: { state: 'disabled' } } })
expect(store.getState().telegram).toEqual({ state: 'disabled' })
expect(() => disconnect()).not.toThrow()
})
})
+49
View File
@@ -0,0 +1,49 @@
import type {
ChannelRuntimeStatus,
ChannelRuntimeStatusChange,
ChannelSettingsSnapshot,
ManagedChannel
} from '../../shared/channel-settings-contracts'
import type { DesktopApi } from '../../shared/contracts'
import { projectChannels } from '../../shared/assistant-contracts'
export function createChannelStatusStore() {
let state: Partial<Record<ManagedChannel, ChannelRuntimeStatus>> = {}
const eventChannels = new Set<ManagedChannel>()
const listeners = new Set<() => void>()
const update = ({ channel, status }: ChannelRuntimeStatusChange): void => {
const current = state[channel]
if (current?.state === status.state && current.lastError === status.lastError) return
state = { ...state, [channel]: status }
listeners.forEach((listener) => listener())
}
return {
getState: () => state,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
connect: (api: DesktopApi['channels']) => {
let active = true
const unsubscribe = api?.onStatusChanged?.((change: ChannelRuntimeStatusChange) => {
if (!active) return
eventChannels.add(change.channel)
update(change)
})
return () => {
active = false
unsubscribe?.()
}
},
seed: (snapshot: ChannelSettingsSnapshot) => {
// A status push received during loading is newer than the snapshot baseline.
for (const channel of projectChannels) {
if (!eventChannels.has(channel)) update({ channel, status: snapshot[channel].status })
}
}
}
}
export type ChannelStatusStore = ReturnType<typeof createChannelStatusStore>
@@ -17,7 +17,8 @@ export const integrations = {
tabs: {
weixin: 'WeChat ClawBot',
wecom: 'WeCom',
dingtalk: 'DingTalk'
dingtalk: 'DingTalk',
telegram: 'Telegram'
},
status: {
disabled: 'Not enabled',
@@ -72,7 +73,8 @@ export const integrations = {
},
secrets: {
wecom: 'Secret',
dingtalk: 'Client Secret'
dingtalk: 'Client Secret',
telegram: 'Bot Token'
},
environmentSource: 'Provided by environment variables',
secretSaved: 'Secret saved with encryption',
@@ -94,6 +96,23 @@ export const integrations = {
testing: 'Testing…',
testConnection: 'Test {{channel}} connection'
},
telegram: {
unconfigured: 'Not configured',
botFather: 'Open official BotFather',
setup: 'Send /newbot to BotFather and enter the Token here. Test, enable and save the channel, then open a private chat with your Bot. You must send /start first, then /whoami to get your numeric user ID. Add that ID below and save again.',
access: 'Private text messages only. Allowed users can request operations using this project\'s working directory, Runtime and enabled tools.',
connectionHelp: 'Connection and offline messages',
network: 'GoodBuddy must stay running. Telegram follows the operating system proxy settings; configure your system proxy if needed, then test this connection. Browser access alone does not verify the channel. Use this Bot with only one GoodBuddy instance and remove any existing webhook at its source.',
offline: 'Re-enabling may process pending messages retained by Telegram for up to 24 hours, including tool requests. Interrupted tasks do not automatically resume after a crash. Check desktop task records and actual results before resending.',
noUsers: 'No users authorized. With the channel enabled and saved, /start and /whoami provide setup help only; business requests are not processed.',
sendersHelp: 'Enter numeric Telegram user IDs, one per line or separated by commas (up to 100). Usernames do not grant access.',
invalidSenders: 'Enter up to 100 positive numeric user IDs (at most 256 digits each), not @usernames.',
keepToken: 'Leave blank to keep the saved Token',
enterToken: 'Enter the BotFather Token',
clearToken: 'Clear the saved Token on save (stops the connection; keeps history)',
bot: 'Bot:',
verified: 'Telegram credentials verified. Testing does not enable message receiving.'
},
qr: {
title: 'Connect WeChat ClawBot',
instructions:
@@ -32,6 +32,10 @@ export const warnings = {
'The WeCom Secret cannot be read. Re-enter or clear this credential.',
'channel-dingtalk-credential-unreadable':
'The DingTalk Client Secret cannot be read. Re-enter or clear this credential.',
'channel-telegram-environment-invalid':
'The Telegram environment configuration is invalid or incomplete, so the channel remains off.',
'channel-telegram-credential-unreadable':
'The Telegram Bot Token cannot be read. Re-enter or clear this credential.',
'channel-runtime-selections-repaired':
'Repaired {{count}} unavailable backend selections for unattended channels. Review each channel project setting.'
} as const satisfies TranslationShape<typeof chineseWarnings>
@@ -14,7 +14,8 @@ export const integrations = {
tabs: {
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉'
dingtalk: '钉钉',
telegram: 'Telegram'
},
status: {
disabled: '未启用',
@@ -63,7 +64,8 @@ export const integrations = {
},
secrets: {
wecom: 'Secret',
dingtalk: 'Client Secret'
dingtalk: 'Client Secret',
telegram: 'Bot Token'
},
environmentSource: '由环境变量提供',
secretSaved: 'Secret 已加密保存',
@@ -85,6 +87,23 @@ export const integrations = {
testing: '正在测试…',
testConnection: '测试{{channel}}连接'
},
telegram: {
unconfigured: '未配置',
botFather: '打开官方 BotFather',
setup: '向 BotFather 发送 /newbot,将获得的 Token 填入下方。测试后启用并保存通道,再打开与 Bot 的私聊。必须先由你发送 /start,再发送 /whoami 获取自己的数字用户 ID;将 ID 填入白名单后再次保存。',
access: '仅支持普通私聊文字。白名单内的用户可以使用此项目的工作目录、Runtime 和已启用工具发起操作。',
connectionHelp: '连接与离线消息说明',
network: 'GoodBuddy 需要保持运行。Telegram 使用操作系统代理设置;如有需要,请配置系统代理后测试连接。浏览器能够访问不代表通道可用。同一 Bot 仅供一个 GoodBuddy 实例使用,已有 webhook 需在原接入处解除。',
offline: '重新启用后可能处理 Telegram 保留的离线消息,包括工具请求;平台最多保留 24 小时。应用异常退出后不会自动续跑中断任务,请先核对桌面任务记录和实际结果,再决定是否重发。',
noUsers: '尚未授权用户。启用并保存后,/start 和 /whoami 仅提供配置帮助,不处理业务请求。',
sendersHelp: '填写 Telegram 数字用户 ID,每行一个或用逗号分隔,最多 100 个;用户名不能用于授权。',
invalidSenders: '请填写正整数用户 ID,最多 100 个,每个不超过 256 位,不要填写 @用户名。',
keepToken: '留空以保留已保存的 Token',
enterToken: '请输入 BotFather 提供的 Token',
clearToken: '保存时清除 Token(停止连接,保留历史)',
bot: 'Bot:',
verified: 'Telegram 凭据验证成功;测试不会启用消息接收。'
},
qr: {
title: '绑定微信 ClawBot',
instructions:
@@ -29,6 +29,10 @@ export const warnings = {
'企业微信 Secret 无法读取。请重新输入或清除该凭据。',
'channel-dingtalk-credential-unreadable':
'钉钉 Client Secret 无法读取。请重新输入或清除该凭据。',
'channel-telegram-environment-invalid':
'Telegram 环境变量配置无效或不完整,通道保持关闭。',
'channel-telegram-credential-unreadable':
'Telegram Bot Token 无法读取。请重新输入或清除该凭据。',
'channel-runtime-selections-repaired':
'已修复 {{count}} 个无人值守通道的不可用后端选择。请检查各通道项目设置。'
} as const