fix: tui can recevie message and auto load gateway
Browse files- src/components/PromptInput/PromptInputFooter.tsx +19 -0
- src/hooks/useTelegramBridge.ts +128 -19
- src/screens/REPL.tsx +2 -1
- src/services/api/copilotClient.ts +1150 -0
- src/services/api/customOpenAIClient.ts +308 -0
- src/services/telegram/TelegramService.ts +275 -38
- src/services/telegram/interactiveCommands.ts +653 -0
- src/services/telegram/telegramConfig.ts +8 -0
- src/services/telegram/telegramTypes.ts +57 -0
src/components/PromptInput/PromptInputFooter.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { useSettings } from '../../hooks/useSettings.js';
|
|
| 10 |
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
| 11 |
import { Box, Text } from '../../ink.js';
|
| 12 |
import type { MCPServerConnection } from '../../services/mcp/types.js';
|
|
|
|
| 13 |
import { useAppState } from '../../state/AppState.js';
|
| 14 |
import type { ToolPermissionContext } from '../../Tool.js';
|
| 15 |
import type { Message } from '../../types/message.js';
|
|
@@ -144,6 +145,7 @@ function PromptInputFooter({
|
|
| 144 |
<Box flexShrink={1} gap={1}>
|
| 145 |
{isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
|
| 146 |
{"external" === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
|
|
|
|
| 147 |
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
|
| 148 |
</Box>
|
| 149 |
</Box>
|
|
@@ -151,6 +153,23 @@ function PromptInputFooter({
|
|
| 151 |
</>;
|
| 152 |
}
|
| 153 |
export default memo(PromptInputFooter);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
type BridgeStatusProps = {
|
| 155 |
bridgeSelected: boolean;
|
| 156 |
};
|
|
|
|
| 10 |
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
|
| 11 |
import { Box, Text } from '../../ink.js';
|
| 12 |
import type { MCPServerConnection } from '../../services/mcp/types.js';
|
| 13 |
+
import { telegramService } from '../../services/telegram/TelegramService.js';
|
| 14 |
import { useAppState } from '../../state/AppState.js';
|
| 15 |
import type { ToolPermissionContext } from '../../Tool.js';
|
| 16 |
import type { Message } from '../../types/message.js';
|
|
|
|
| 145 |
<Box flexShrink={1} gap={1}>
|
| 146 |
{isFullscreen ? null : <Notifications apiKeyStatus={apiKeyStatus} autoUpdaterResult={autoUpdaterResult} debug={debug} isAutoUpdating={isAutoUpdating} verbose={verbose} messages={messages} onAutoUpdaterResult={onAutoUpdaterResult} onChangeIsUpdating={onChangeIsUpdating} ideSelection={ideSelection} mcpClients={mcpClients} isInputWrapped={isInputWrapped} isNarrow={isNarrow} />}
|
| 147 |
{"external" === 'ant' && isUndercover() && <Text dimColor>undercover</Text>}
|
| 148 |
+
<TelegramStatusIndicator />
|
| 149 |
<BridgeStatusIndicator bridgeSelected={bridgeSelected} />
|
| 150 |
</Box>
|
| 151 |
</Box>
|
|
|
|
| 153 |
</>;
|
| 154 |
}
|
| 155 |
export default memo(PromptInputFooter);
|
| 156 |
+
|
| 157 |
+
function TelegramStatusIndicator(): React.ReactNode {
|
| 158 |
+
const state = React.useSyncExternalStore(
|
| 159 |
+
telegramService.subscribe,
|
| 160 |
+
telegramService.getStateSnapshot,
|
| 161 |
+
telegramService.getStateSnapshot,
|
| 162 |
+
);
|
| 163 |
+
|
| 164 |
+
if (state.status !== 'running' && state.status !== 'starting') {
|
| 165 |
+
return null;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
return <Text color={state.status === 'running' ? 'notice' : 'warning'}>
|
| 169 |
+
✈
|
| 170 |
+
</Text>;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
type BridgeStatusProps = {
|
| 174 |
bridgeSelected: boolean;
|
| 175 |
};
|
src/hooks/useTelegramBridge.ts
CHANGED
|
@@ -1,49 +1,132 @@
|
|
| 1 |
import { useEffect, useRef } from 'react'
|
|
|
|
| 2 |
import { telegramService } from '../services/telegram/TelegramService.js'
|
| 3 |
import {
|
| 4 |
TELEGRAM_CHANNEL_SERVER,
|
| 5 |
type TelegramInboundEvent,
|
| 6 |
} from '../services/telegram/telegramTypes.js'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
import type { Message } from '../types/message.js'
|
| 8 |
import { enqueue } from '../utils/messageQueueManager.js'
|
| 9 |
import { getContentText } from '../utils/messages.js'
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
type Props = {
|
| 13 |
messages: Message[]
|
| 14 |
isLoading: boolean
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
type ActiveTelegramTurn = {
|
| 18 |
chatId: string
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
-
export function useTelegramBridge({ messages, isLoading }: Props): void {
|
| 23 |
const pendingInboundRef = useRef<TelegramInboundEvent[]>([])
|
| 24 |
const activeTurnRef = useRef<ActiveTelegramTurn | null>(null)
|
| 25 |
const lastProcessedMessageCountRef = useRef(messages.length)
|
| 26 |
const previousLoadingRef = useRef(isLoading)
|
|
|
|
| 27 |
|
| 28 |
useEffect(() => {
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
origin: {
|
| 36 |
-
kind: 'channel',
|
| 37 |
-
server: TELEGRAM_CHANNEL_SERVER,
|
| 38 |
-
} as const,
|
| 39 |
-
})
|
| 40 |
})
|
| 41 |
}, [])
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
useEffect(() => {
|
| 44 |
const newMessages = messages.slice(lastProcessedMessageCountRef.current)
|
| 45 |
|
|
|
|
|
|
|
| 46 |
for (const message of newMessages) {
|
|
|
|
|
|
|
| 47 |
if (
|
| 48 |
message.type === 'user' &&
|
| 49 |
message.origin?.kind === 'channel' &&
|
|
@@ -53,15 +136,32 @@ export function useTelegramBridge({ messages, isLoading }: Props): void {
|
|
| 53 |
if (inbound) {
|
| 54 |
activeTurnRef.current = {
|
| 55 |
chatId: inbound.chatId,
|
|
|
|
| 56 |
}
|
|
|
|
| 57 |
}
|
| 58 |
continue
|
| 59 |
}
|
| 60 |
|
| 61 |
if (message.type === 'assistant' && activeTurnRef.current) {
|
| 62 |
const text = getContentText(message.message.content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
if (text) {
|
| 64 |
-
activeTurnRef.current.
|
| 65 |
}
|
| 66 |
}
|
| 67 |
}
|
|
@@ -73,24 +173,33 @@ export function useTelegramBridge({ messages, isLoading }: Props): void {
|
|
| 73 |
const wasLoading = previousLoadingRef.current
|
| 74 |
previousLoadingRef.current = isLoading
|
| 75 |
|
|
|
|
|
|
|
| 76 |
if (!wasLoading || isLoading || !activeTurnRef.current) {
|
| 77 |
return
|
| 78 |
}
|
| 79 |
|
| 80 |
-
const { chatId,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
activeTurnRef.current = null
|
| 82 |
|
| 83 |
void telegramService
|
| 84 |
.sendMessage(
|
| 85 |
chatId,
|
| 86 |
-
|
| 87 |
)
|
| 88 |
.catch(error => {
|
| 89 |
-
|
| 90 |
`[telegram] failed to send outbound reply: ${
|
| 91 |
error instanceof Error ? error.message : String(error)
|
| 92 |
}`,
|
| 93 |
-
|
| 94 |
)
|
| 95 |
})
|
| 96 |
}, [isLoading])
|
|
|
|
| 1 |
import { useEffect, useRef } from 'react'
|
| 2 |
+
import type { AppStateStore } from '../state/AppState.js'
|
| 3 |
import { telegramService } from '../services/telegram/TelegramService.js'
|
| 4 |
import {
|
| 5 |
TELEGRAM_CHANNEL_SERVER,
|
| 6 |
type TelegramInboundEvent,
|
| 7 |
} from '../services/telegram/telegramTypes.js'
|
| 8 |
+
import {
|
| 9 |
+
handleTelegramCallback,
|
| 10 |
+
logTelegramInteractiveError,
|
| 11 |
+
maybeHandleTelegramInteractiveInput,
|
| 12 |
+
} from '../services/telegram/interactiveCommands.js'
|
| 13 |
+
import { hasTelegramRuntimeConfig } from '../services/telegram/telegramConfig.js'
|
| 14 |
import type { Message } from '../types/message.js'
|
| 15 |
import { enqueue } from '../utils/messageQueueManager.js'
|
| 16 |
import { getContentText } from '../utils/messages.js'
|
| 17 |
+
|
| 18 |
+
// 日志函数 - 只写入文件
|
| 19 |
+
function logTelegramDebug(message: string, level: 'debug' | 'error' | 'info' = 'debug'): void {
|
| 20 |
+
try {
|
| 21 |
+
const fs = require('node:fs')
|
| 22 |
+
const path = require('node:path')
|
| 23 |
+
const LOG_FILE_PATH = path.join(process.cwd(), 'log.md')
|
| 24 |
+
const timestamp = new Date().toISOString()
|
| 25 |
+
const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`
|
| 26 |
+
fs.appendFileSync(LOG_FILE_PATH, logEntry, 'utf-8')
|
| 27 |
+
} catch (error) {
|
| 28 |
+
// 忽略文件写入错误
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
|
| 32 |
type Props = {
|
| 33 |
messages: Message[]
|
| 34 |
isLoading: boolean
|
| 35 |
+
store: AppStateStore
|
| 36 |
}
|
| 37 |
|
| 38 |
type ActiveTelegramTurn = {
|
| 39 |
chatId: string
|
| 40 |
+
responseParts: string[]
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function stripXmlTags(text: string): string {
|
| 44 |
+
return text.replace(/<[^>]+>/g, '').trim()
|
| 45 |
}
|
| 46 |
|
| 47 |
+
export function useTelegramBridge({ messages, isLoading, store }: Props): void {
|
| 48 |
const pendingInboundRef = useRef<TelegramInboundEvent[]>([])
|
| 49 |
const activeTurnRef = useRef<ActiveTelegramTurn | null>(null)
|
| 50 |
const lastProcessedMessageCountRef = useRef(messages.length)
|
| 51 |
const previousLoadingRef = useRef(isLoading)
|
| 52 |
+
const autoStartAttemptedRef = useRef(false)
|
| 53 |
|
| 54 |
useEffect(() => {
|
| 55 |
+
if (autoStartAttemptedRef.current) return
|
| 56 |
+
autoStartAttemptedRef.current = true
|
| 57 |
+
if (!hasTelegramRuntimeConfig()) return
|
| 58 |
+
|
| 59 |
+
void telegramService.startFromSavedConfig().catch(error => {
|
| 60 |
+
logTelegramDebug(`[telegram] auto-start failed: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
})
|
| 62 |
}, [])
|
| 63 |
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
return telegramService.subscribeToInbound(event => {
|
| 66 |
+
void (async () => {
|
| 67 |
+
try {
|
| 68 |
+
logTelegramDebug(`[telegram] received inbound event: chatId=${event.chatId}, text=${event.text.slice(0, 50)}`)
|
| 69 |
+
|
| 70 |
+
if (await maybeHandleTelegramInteractiveInput(event, store)) {
|
| 71 |
+
return
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
pendingInboundRef.current.push(event)
|
| 75 |
+
logTelegramDebug(`[telegram] added to pendingInboundRef: chatId=${event.chatId}`)
|
| 76 |
+
|
| 77 |
+
enqueue({
|
| 78 |
+
value: event.text,
|
| 79 |
+
mode: 'prompt',
|
| 80 |
+
skipSlashCommands: true,
|
| 81 |
+
bridgeOrigin: true,
|
| 82 |
+
origin: {
|
| 83 |
+
kind: 'channel',
|
| 84 |
+
server: TELEGRAM_CHANNEL_SERVER,
|
| 85 |
+
} as const,
|
| 86 |
+
})
|
| 87 |
+
} catch (error) {
|
| 88 |
+
logTelegramInteractiveError(error)
|
| 89 |
+
await telegramService.sendMessage(
|
| 90 |
+
event.chatId,
|
| 91 |
+
`Telegram 交互处理失败: ${
|
| 92 |
+
error instanceof Error ? error.message : String(error)
|
| 93 |
+
}`,
|
| 94 |
+
).catch(() => {})
|
| 95 |
+
}
|
| 96 |
+
})()
|
| 97 |
+
})
|
| 98 |
+
}, [store])
|
| 99 |
+
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
return telegramService.subscribeToCallbacks(event => {
|
| 102 |
+
void (async () => {
|
| 103 |
+
try {
|
| 104 |
+
await handleTelegramCallback(event, store)
|
| 105 |
+
} catch (error) {
|
| 106 |
+
logTelegramInteractiveError(error)
|
| 107 |
+
await telegramService.answerCallbackQuery(
|
| 108 |
+
event.callbackQueryId,
|
| 109 |
+
'处理按钮操作失败',
|
| 110 |
+
).catch(() => {})
|
| 111 |
+
await telegramService.sendMessage(
|
| 112 |
+
event.chatId,
|
| 113 |
+
`Telegram 按钮操作失败: ${
|
| 114 |
+
error instanceof Error ? error.message : String(error)
|
| 115 |
+
}`,
|
| 116 |
+
).catch(() => {})
|
| 117 |
+
}
|
| 118 |
+
})()
|
| 119 |
+
})
|
| 120 |
+
}, [store])
|
| 121 |
+
|
| 122 |
useEffect(() => {
|
| 123 |
const newMessages = messages.slice(lastProcessedMessageCountRef.current)
|
| 124 |
|
| 125 |
+
logTelegramDebug(`[telegram] processing ${newMessages.length} new messages, activeTurn: ${activeTurnRef.current ? 'active' : 'null'}`)
|
| 126 |
+
|
| 127 |
for (const message of newMessages) {
|
| 128 |
+
logTelegramDebug(`[telegram] processing message: type=${message.type}, hasOrigin=!!${message.origin}, origin=${JSON.stringify(message.origin)}`)
|
| 129 |
+
|
| 130 |
if (
|
| 131 |
message.type === 'user' &&
|
| 132 |
message.origin?.kind === 'channel' &&
|
|
|
|
| 136 |
if (inbound) {
|
| 137 |
activeTurnRef.current = {
|
| 138 |
chatId: inbound.chatId,
|
| 139 |
+
responseParts: [],
|
| 140 |
}
|
| 141 |
+
logTelegramDebug(`[telegram] active turn started for chatId: ${inbound.chatId}`)
|
| 142 |
}
|
| 143 |
continue
|
| 144 |
}
|
| 145 |
|
| 146 |
if (message.type === 'assistant' && activeTurnRef.current) {
|
| 147 |
const text = getContentText(message.message.content)
|
| 148 |
+
logTelegramDebug(`[telegram] assistant message, text length: ${text?.length || 0}, content type: ${typeof message.message.content}`)
|
| 149 |
+
if (text) {
|
| 150 |
+
activeTurnRef.current.responseParts.push(text)
|
| 151 |
+
logTelegramDebug(`[telegram] added to responseParts, total parts: ${activeTurnRef.current.responseParts.length}`)
|
| 152 |
+
}
|
| 153 |
+
continue
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
if (
|
| 157 |
+
message.type === 'system' &&
|
| 158 |
+
message.subtype === 'local_command' &&
|
| 159 |
+
activeTurnRef.current
|
| 160 |
+
) {
|
| 161 |
+
const text = stripXmlTags(message.content)
|
| 162 |
+
logTelegramDebug(`[telegram] local_command, text length: ${text?.length || 0}`)
|
| 163 |
if (text) {
|
| 164 |
+
activeTurnRef.current.responseParts.push(text)
|
| 165 |
}
|
| 166 |
}
|
| 167 |
}
|
|
|
|
| 173 |
const wasLoading = previousLoadingRef.current
|
| 174 |
previousLoadingRef.current = isLoading
|
| 175 |
|
| 176 |
+
logTelegramDebug(`[telegram] loading state changed: wasLoading=${wasLoading}, isLoading=${isLoading}, hasActiveTurn=!!${activeTurnRef.current}`)
|
| 177 |
+
|
| 178 |
if (!wasLoading || isLoading || !activeTurnRef.current) {
|
| 179 |
return
|
| 180 |
}
|
| 181 |
|
| 182 |
+
const { chatId, responseParts } = activeTurnRef.current
|
| 183 |
+
const messageToSend = responseParts.join('\n\n').trim() ||
|
| 184 |
+
'这一轮没有可回传的文本结果,请查看本地终端会话。'
|
| 185 |
+
|
| 186 |
+
logTelegramDebug(`[telegram] sending message to chatId: ${chatId}, parts count: ${responseParts.length}, total length: ${messageToSend.length}`)
|
| 187 |
+
logTelegramDebug(`[telegram] message content: ${JSON.stringify(messageToSend)}`)
|
| 188 |
+
logTelegramDebug(`[telegram] response parts: ${JSON.stringify(responseParts)}`)
|
| 189 |
+
|
| 190 |
activeTurnRef.current = null
|
| 191 |
|
| 192 |
void telegramService
|
| 193 |
.sendMessage(
|
| 194 |
chatId,
|
| 195 |
+
messageToSend,
|
| 196 |
)
|
| 197 |
.catch(error => {
|
| 198 |
+
logTelegramDebug(
|
| 199 |
`[telegram] failed to send outbound reply: ${
|
| 200 |
error instanceof Error ? error.message : String(error)
|
| 201 |
}`,
|
| 202 |
+
'error',
|
| 203 |
)
|
| 204 |
})
|
| 205 |
}, [isLoading])
|
src/screens/REPL.tsx
CHANGED
|
@@ -4049,7 +4049,8 @@ export function REPL({
|
|
| 4049 |
|
| 4050 |
useTelegramBridge({
|
| 4051 |
messages,
|
| 4052 |
-
isLoading
|
|
|
|
| 4053 |
});
|
| 4054 |
|
| 4055 |
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
|
|
|
|
| 4049 |
|
| 4050 |
useTelegramBridge({
|
| 4051 |
messages,
|
| 4052 |
+
isLoading,
|
| 4053 |
+
store
|
| 4054 |
});
|
| 4055 |
|
| 4056 |
// Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List)
|
src/services/api/copilotClient.ts
ADDED
|
@@ -0,0 +1,1150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getGlobalConfig, saveGlobalConfig, type ConnectedProviderInfo } from '../../utils/config.js'
|
| 2 |
+
|
| 3 |
+
const COPILOT_API_BASE = 'https://api.githubcopilot.com'
|
| 4 |
+
const MODELS_DEV_URL = 'https://models.dev/api.json'
|
| 5 |
+
|
| 6 |
+
export type CopilotModelInfo = {
|
| 7 |
+
id: string
|
| 8 |
+
label: string
|
| 9 |
+
description: string
|
| 10 |
+
supportedEndpoints?: string[]
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
type CopilotOutputTokenParam = 'max_tokens' | 'max_completion_tokens'
|
| 14 |
+
type CopilotCompatibilityInfo = {
|
| 15 |
+
outputTokenParam?: CopilotOutputTokenParam
|
| 16 |
+
modelSupported?: boolean
|
| 17 |
+
chatCompletionsSupported?: boolean
|
| 18 |
+
updatedAt: number
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
const COPILOT_COMPATIBILITY_CACHE_TTL_MS = 24 * 60 * 60 * 1000
|
| 22 |
+
const COPILOT_CHAT_COMPLETIONS_ENDPOINT = '/chat/completions'
|
| 23 |
+
|
| 24 |
+
export function isCopilotModel(model: string): boolean {
|
| 25 |
+
return model.startsWith('copilot:')
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function getCopilotModelId(model: string): string {
|
| 29 |
+
return model.replace(/^copilot:/, '')
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
export function getCopilotProvider(): ConnectedProviderInfo | undefined {
|
| 33 |
+
const config = getGlobalConfig()
|
| 34 |
+
return config.connectedProviders?.['github-copilot']
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function isCopilotConnected(): boolean {
|
| 38 |
+
return !!getCopilotProvider()?.oauthToken
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const FALLBACK_COPILOT_MODELS: CopilotModelInfo[] = [
|
| 42 |
+
{ id: 'copilot:claude-sonnet-4.6', label: 'Claude Sonnet 4.6', description: 'Claude Sonnet 4.6 via Copilot' },
|
| 43 |
+
{ id: 'copilot:claude-sonnet-4.5', label: 'Claude Sonnet 4.5', description: 'Claude Sonnet 4.5 via Copilot' },
|
| 44 |
+
{ id: 'copilot:claude-sonnet-4', label: 'Claude Sonnet 4', description: 'Claude Sonnet 4 via Copilot' },
|
| 45 |
+
{ id: 'copilot:claude-opus-4.6', label: 'Claude Opus 4.6', description: 'Claude Opus 4.6 via Copilot' },
|
| 46 |
+
{ id: 'copilot:claude-opus-4.5', label: 'Claude Opus 4.5', description: 'Claude Opus 4.5 via Copilot' },
|
| 47 |
+
{ id: 'copilot:claude-opus-41', label: 'Claude Opus 4.1', description: 'Claude Opus 4.1 via Copilot' },
|
| 48 |
+
{ id: 'copilot:claude-haiku-4.5', label: 'Claude Haiku 4.5', description: 'Claude Haiku 4.5 via Copilot' },
|
| 49 |
+
{ id: 'copilot:gpt-5.4', label: 'GPT-5.4', description: 'OpenAI GPT-5.4 via Copilot' },
|
| 50 |
+
{ id: 'copilot:gpt-5.4-mini', label: 'GPT-5.4 mini', description: 'OpenAI GPT-5.4 mini via Copilot' },
|
| 51 |
+
{ id: 'copilot:gpt-5.3-codex', label: 'GPT-5.3-Codex', description: 'OpenAI GPT-5.3-Codex via Copilot' },
|
| 52 |
+
{ id: 'copilot:gpt-5.2-codex', label: 'GPT-5.2-Codex', description: 'OpenAI GPT-5.2-Codex via Copilot' },
|
| 53 |
+
{ id: 'copilot:gpt-5.2', label: 'GPT-5.2', description: 'OpenAI GPT-5.2 via Copilot' },
|
| 54 |
+
{ id: 'copilot:gpt-5.1', label: 'GPT-5.1', description: 'OpenAI GPT-5.1 via Copilot' },
|
| 55 |
+
{ id: 'copilot:gpt-5.1-codex', label: 'GPT-5.1-Codex', description: 'OpenAI GPT-5.1-Codex via Copilot' },
|
| 56 |
+
{ id: 'copilot:gpt-5.1-codex-mini', label: 'GPT-5.1-Codex-mini', description: 'OpenAI GPT-5.1-Codex-mini via Copilot' },
|
| 57 |
+
{ id: 'copilot:gpt-5.1-codex-max', label: 'GPT-5.1-Codex-max', description: 'OpenAI GPT-5.1-Codex-max via Copilot' },
|
| 58 |
+
{ id: 'copilot:gpt-5', label: 'GPT-5', description: 'OpenAI GPT-5 via Copilot' },
|
| 59 |
+
{ id: 'copilot:gpt-5-mini', label: 'GPT-5-mini', description: 'OpenAI GPT-5-mini via Copilot' },
|
| 60 |
+
{ id: 'copilot:gpt-4.1', label: 'GPT-4.1', description: 'OpenAI GPT-4.1 via Copilot' },
|
| 61 |
+
{ id: 'copilot:gpt-4o', label: 'GPT-4o', description: 'OpenAI GPT-4o via Copilot' },
|
| 62 |
+
{ id: 'copilot:gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview', description: 'Google Gemini 3.1 Pro via Copilot' },
|
| 63 |
+
{ id: 'copilot:gemini-3-pro-preview', label: 'Gemini 3 Pro Preview', description: 'Google Gemini 3 Pro via Copilot' },
|
| 64 |
+
{ id: 'copilot:gemini-3-flash-preview', label: 'Gemini 3 Flash', description: 'Google Gemini 3 Flash via Copilot' },
|
| 65 |
+
{ id: 'copilot:gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Google Gemini 2.5 Pro via Copilot' },
|
| 66 |
+
{ id: 'copilot:grok-code-fast-1', label: 'Grok Code Fast 1', description: 'xAI Grok Code Fast 1 via Copilot' },
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
let cachedModels: CopilotModelInfo[] | null = null
|
| 70 |
+
let copilotModelsRefreshPromise: Promise<void> | null = null
|
| 71 |
+
|
| 72 |
+
type CopilotApiModel = {
|
| 73 |
+
id: string
|
| 74 |
+
name?: string
|
| 75 |
+
model_picker_enabled?: boolean
|
| 76 |
+
policy?: { state?: string }
|
| 77 |
+
supported_endpoints?: string[]
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function supportsCopilotChatCompletions(model: CopilotModelInfo): boolean {
|
| 81 |
+
return (
|
| 82 |
+
!model.supportedEndpoints ||
|
| 83 |
+
model.supportedEndpoints.includes(COPILOT_CHAT_COMPLETIONS_ENDPOINT)
|
| 84 |
+
)
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export async function fetchCopilotModelsFromApi(): Promise<CopilotModelInfo[] | null> {
|
| 88 |
+
const provider = getCopilotProvider()
|
| 89 |
+
if (!provider?.oauthToken) return null
|
| 90 |
+
|
| 91 |
+
const response = await fetch(`${COPILOT_API_BASE}/models`, {
|
| 92 |
+
headers: {
|
| 93 |
+
Authorization: `Bearer ${provider.oauthToken}`,
|
| 94 |
+
'User-Agent': 'claude-code/2.1.88',
|
| 95 |
+
'Openai-Intent': 'conversation-edits',
|
| 96 |
+
'x-initiator': 'user',
|
| 97 |
+
},
|
| 98 |
+
signal: AbortSignal.timeout(10_000),
|
| 99 |
+
})
|
| 100 |
+
if (!response.ok) {
|
| 101 |
+
throw new Error(`Copilot models API failed (${response.status})`)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const data = await response.json() as {
|
| 105 |
+
data?: CopilotApiModel[]
|
| 106 |
+
}
|
| 107 |
+
if (!Array.isArray(data.data) || data.data.length === 0) {
|
| 108 |
+
return null
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
const models = data.data
|
| 112 |
+
.filter(model => model.model_picker_enabled !== false)
|
| 113 |
+
.filter(model => model.policy?.state !== 'disabled')
|
| 114 |
+
.map(
|
| 115 |
+
(model): CopilotModelInfo => ({
|
| 116 |
+
id: `copilot:${model.id}`,
|
| 117 |
+
label: model.name || model.id,
|
| 118 |
+
description: `${model.name || model.id} via Copilot`,
|
| 119 |
+
supportedEndpoints: model.supported_endpoints,
|
| 120 |
+
}),
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return models.length > 0 ? models : null
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
export async function fetchCopilotModelsFromModelsDev(): Promise<CopilotModelInfo[]> {
|
| 127 |
+
try {
|
| 128 |
+
const response = await fetch(MODELS_DEV_URL, {
|
| 129 |
+
signal: AbortSignal.timeout(10_000),
|
| 130 |
+
})
|
| 131 |
+
if (!response.ok) return FALLBACK_COPILOT_MODELS
|
| 132 |
+
|
| 133 |
+
const data = await response.json() as Record<string, { models?: Record<string, { name?: string }> }>
|
| 134 |
+
const copilotProvider = data['github-copilot']
|
| 135 |
+
if (!copilotProvider?.models) return FALLBACK_COPILOT_MODELS
|
| 136 |
+
|
| 137 |
+
const models: CopilotModelInfo[] = Object.entries(copilotProvider.models).map(
|
| 138 |
+
([modelId, info]) => ({
|
| 139 |
+
id: `copilot:${modelId}`,
|
| 140 |
+
label: info.name || modelId,
|
| 141 |
+
description: `${info.name || modelId} via Copilot`,
|
| 142 |
+
}),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return models.length > 0 ? models : FALLBACK_COPILOT_MODELS
|
| 146 |
+
} catch {
|
| 147 |
+
return FALLBACK_COPILOT_MODELS
|
| 148 |
+
}
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
export async function fetchCopilotModels(): Promise<CopilotModelInfo[]> {
|
| 152 |
+
try {
|
| 153 |
+
const apiModels = await fetchCopilotModelsFromApi()
|
| 154 |
+
if (apiModels && apiModels.length > 0) {
|
| 155 |
+
return apiModels
|
| 156 |
+
}
|
| 157 |
+
} catch {}
|
| 158 |
+
|
| 159 |
+
return fetchCopilotModelsFromModelsDev()
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function hasCopilotEndpointMetadata(models: CopilotModelInfo[]): boolean {
|
| 163 |
+
return models.some(model => Array.isArray(model.supportedEndpoints))
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
function shouldUseCachedCopilotModels(cache: {
|
| 167 |
+
models: CopilotModelInfo[]
|
| 168 |
+
fetchedAt: number
|
| 169 |
+
} | null | undefined): boolean {
|
| 170 |
+
if (!cache || cache.models.length === 0) return false
|
| 171 |
+
if (!hasCopilotEndpointMetadata(cache.models)) return false
|
| 172 |
+
return Date.now() - cache.fetchedAt < 3600_000
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
function refreshCopilotModelsCacheInBackground(): void {
|
| 176 |
+
if (copilotModelsRefreshPromise || !isCopilotConnected()) {
|
| 177 |
+
return
|
| 178 |
+
}
|
| 179 |
+
copilotModelsRefreshPromise = (async () => {
|
| 180 |
+
const models = await fetchCopilotModels()
|
| 181 |
+
cachedModels = models
|
| 182 |
+
saveGlobalConfig(current => ({
|
| 183 |
+
...current,
|
| 184 |
+
copilotModelsCache: { models, fetchedAt: Date.now() },
|
| 185 |
+
}))
|
| 186 |
+
})()
|
| 187 |
+
.catch(() => {})
|
| 188 |
+
.finally(() => {
|
| 189 |
+
copilotModelsRefreshPromise = null
|
| 190 |
+
})
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
export async function getCopilotModels(): Promise<CopilotModelInfo[]> {
|
| 194 |
+
if (cachedModels) return filterUnavailableCopilotModels(cachedModels)
|
| 195 |
+
|
| 196 |
+
const config = getGlobalConfig()
|
| 197 |
+
const cached = config.copilotModelsCache
|
| 198 |
+
if (shouldUseCachedCopilotModels(cached)) {
|
| 199 |
+
cachedModels = cached.models
|
| 200 |
+
return filterUnavailableCopilotModels(cachedModels)
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const models = await fetchCopilotModels()
|
| 204 |
+
cachedModels = models
|
| 205 |
+
|
| 206 |
+
saveGlobalConfig(current => ({
|
| 207 |
+
...current,
|
| 208 |
+
copilotModelsCache: { models, fetchedAt: Date.now() },
|
| 209 |
+
}))
|
| 210 |
+
|
| 211 |
+
return filterUnavailableCopilotModels(models)
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
export function getCopilotModelsCached(): CopilotModelInfo[] {
|
| 215 |
+
if (cachedModels) {
|
| 216 |
+
if (!hasCopilotEndpointMetadata(cachedModels)) {
|
| 217 |
+
refreshCopilotModelsCacheInBackground()
|
| 218 |
+
}
|
| 219 |
+
return filterUnavailableCopilotModels(cachedModels)
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
const config = getGlobalConfig()
|
| 223 |
+
const cached = config.copilotModelsCache
|
| 224 |
+
if (cached && cached.models.length > 0) {
|
| 225 |
+
cachedModels = cached.models
|
| 226 |
+
if (!shouldUseCachedCopilotModels(cached)) {
|
| 227 |
+
refreshCopilotModelsCacheInBackground()
|
| 228 |
+
}
|
| 229 |
+
return filterUnavailableCopilotModels(cachedModels)
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
refreshCopilotModelsCacheInBackground()
|
| 233 |
+
return filterUnavailableCopilotModels(FALLBACK_COPILOT_MODELS)
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
export { FALLBACK_COPILOT_MODELS as COPILOT_MODELS }
|
| 237 |
+
|
| 238 |
+
function getCachedCopilotCompatibility(
|
| 239 |
+
modelId: string,
|
| 240 |
+
): CopilotCompatibilityInfo | undefined {
|
| 241 |
+
const compatibility = getGlobalConfig().copilotCompatibilityCache?.[modelId]
|
| 242 |
+
if (!compatibility) return undefined
|
| 243 |
+
if (Date.now() - compatibility.updatedAt > COPILOT_COMPATIBILITY_CACHE_TTL_MS) {
|
| 244 |
+
return undefined
|
| 245 |
+
}
|
| 246 |
+
return compatibility
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function filterUnavailableCopilotModels(
|
| 250 |
+
models: CopilotModelInfo[],
|
| 251 |
+
): CopilotModelInfo[] {
|
| 252 |
+
return models.filter(model => {
|
| 253 |
+
if (!supportsCopilotChatCompletions(model)) {
|
| 254 |
+
return false
|
| 255 |
+
}
|
| 256 |
+
const compatibility = getCachedCopilotCompatibility(
|
| 257 |
+
getCopilotModelId(model.id),
|
| 258 |
+
)
|
| 259 |
+
return (
|
| 260 |
+
compatibility?.modelSupported !== false &&
|
| 261 |
+
compatibility?.chatCompletionsSupported !== false
|
| 262 |
+
)
|
| 263 |
+
})
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function saveCopilotCompatibility(
|
| 267 |
+
modelId: string,
|
| 268 |
+
updates: Partial<CopilotCompatibilityInfo>,
|
| 269 |
+
): void {
|
| 270 |
+
saveGlobalConfig(current => {
|
| 271 |
+
const existing = current.copilotCompatibilityCache?.[modelId]
|
| 272 |
+
const next = {
|
| 273 |
+
...existing,
|
| 274 |
+
...updates,
|
| 275 |
+
updatedAt: Date.now(),
|
| 276 |
+
} satisfies CopilotCompatibilityInfo
|
| 277 |
+
|
| 278 |
+
if (
|
| 279 |
+
existing?.outputTokenParam === next.outputTokenParam &&
|
| 280 |
+
existing?.modelSupported === next.modelSupported &&
|
| 281 |
+
existing?.chatCompletionsSupported === next.chatCompletionsSupported
|
| 282 |
+
) {
|
| 283 |
+
return current
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
return {
|
| 287 |
+
...current,
|
| 288 |
+
copilotCompatibilityCache: {
|
| 289 |
+
...current.copilotCompatibilityCache,
|
| 290 |
+
[modelId]: next,
|
| 291 |
+
},
|
| 292 |
+
}
|
| 293 |
+
})
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function getPreferredCopilotOutputTokenParam(
|
| 297 |
+
modelId: string,
|
| 298 |
+
): CopilotOutputTokenParam {
|
| 299 |
+
return (
|
| 300 |
+
getCachedCopilotCompatibility(modelId)?.outputTokenParam ??
|
| 301 |
+
'max_tokens'
|
| 302 |
+
)
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
function savePreferredCopilotOutputTokenParam(
|
| 306 |
+
modelId: string,
|
| 307 |
+
outputTokenParam: CopilotOutputTokenParam,
|
| 308 |
+
): void {
|
| 309 |
+
saveCopilotCompatibility(modelId, { outputTokenParam })
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
function buildCopilotChatRequestBody(params: {
|
| 313 |
+
modelId: string
|
| 314 |
+
messages: OpenAIMessage[]
|
| 315 |
+
isStreaming: boolean
|
| 316 |
+
maxTokens?: number
|
| 317 |
+
tools?: OpenAITool[]
|
| 318 |
+
outputTokenParam: CopilotOutputTokenParam
|
| 319 |
+
}): Record<string, unknown> {
|
| 320 |
+
const requestBody: Record<string, unknown> = {
|
| 321 |
+
model: params.modelId,
|
| 322 |
+
messages: params.messages,
|
| 323 |
+
stream: params.isStreaming,
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
if (params.maxTokens) {
|
| 327 |
+
requestBody[params.outputTokenParam] = params.maxTokens
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
if (params.tools && params.tools.length > 0) {
|
| 331 |
+
requestBody.tools = params.tools
|
| 332 |
+
requestBody.tool_choice = 'auto'
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
return requestBody
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
async function createCopilotErrorInfo(response: Response): Promise<{
|
| 339 |
+
message?: string
|
| 340 |
+
code?: string
|
| 341 |
+
}> {
|
| 342 |
+
const text = await response
|
| 343 |
+
.clone()
|
| 344 |
+
.text()
|
| 345 |
+
.catch(() => '')
|
| 346 |
+
if (!text) return {}
|
| 347 |
+
try {
|
| 348 |
+
const parsed = JSON.parse(text) as {
|
| 349 |
+
error?: { message?: string; code?: string }
|
| 350 |
+
}
|
| 351 |
+
return {
|
| 352 |
+
message: parsed.error?.message,
|
| 353 |
+
code: parsed.error?.code,
|
| 354 |
+
}
|
| 355 |
+
} catch {
|
| 356 |
+
return {}
|
| 357 |
+
}
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
function createCachedCopilotErrorResponse(
|
| 361 |
+
message: string,
|
| 362 |
+
code: string,
|
| 363 |
+
): Response {
|
| 364 |
+
return new Response(JSON.stringify({ error: { message, code } }), {
|
| 365 |
+
status: 400,
|
| 366 |
+
headers: { 'Content-Type': 'application/json' },
|
| 367 |
+
})
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
function getSuggestedCopilotOutputTokenParam(
|
| 371 |
+
currentParam: CopilotOutputTokenParam,
|
| 372 |
+
errorMessage: string | undefined,
|
| 373 |
+
): CopilotOutputTokenParam | undefined {
|
| 374 |
+
if (!errorMessage) return undefined
|
| 375 |
+
if (
|
| 376 |
+
currentParam === 'max_tokens' &&
|
| 377 |
+
errorMessage.includes("Use 'max_completion_tokens' instead")
|
| 378 |
+
) {
|
| 379 |
+
return 'max_completion_tokens'
|
| 380 |
+
}
|
| 381 |
+
if (
|
| 382 |
+
currentParam === 'max_completion_tokens' &&
|
| 383 |
+
errorMessage.includes("Use 'max_tokens' instead")
|
| 384 |
+
) {
|
| 385 |
+
return 'max_tokens'
|
| 386 |
+
}
|
| 387 |
+
return undefined
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
async function postCopilotChatCompletion(params: {
|
| 391 |
+
oauthToken: string
|
| 392 |
+
requestBody: Record<string, unknown>
|
| 393 |
+
signal?: AbortSignal
|
| 394 |
+
}): Promise<Response> {
|
| 395 |
+
return fetch(`${COPILOT_API_BASE}/chat/completions`, {
|
| 396 |
+
method: 'POST',
|
| 397 |
+
headers: {
|
| 398 |
+
'Content-Type': 'application/json',
|
| 399 |
+
Authorization: `Bearer ${params.oauthToken}`,
|
| 400 |
+
'User-Agent': 'claude-code/2.1.88',
|
| 401 |
+
'Openai-Intent': 'conversation-edits',
|
| 402 |
+
'x-initiator': 'user',
|
| 403 |
+
},
|
| 404 |
+
body: JSON.stringify(params.requestBody),
|
| 405 |
+
signal: params.signal,
|
| 406 |
+
})
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
function saveCopilotCompatibilityFromError(
|
| 410 |
+
modelId: string,
|
| 411 |
+
errorInfo: { code?: string },
|
| 412 |
+
): void {
|
| 413 |
+
if (errorInfo.code === 'model_not_supported') {
|
| 414 |
+
saveCopilotCompatibility(modelId, {
|
| 415 |
+
modelSupported: false,
|
| 416 |
+
chatCompletionsSupported: false,
|
| 417 |
+
})
|
| 418 |
+
} else if (errorInfo.code === 'unsupported_api_for_model') {
|
| 419 |
+
saveCopilotCompatibility(modelId, {
|
| 420 |
+
chatCompletionsSupported: false,
|
| 421 |
+
})
|
| 422 |
+
}
|
| 423 |
+
}
|
| 424 |
+
|
| 425 |
+
async function sendCopilotChatCompletion(params: {
|
| 426 |
+
oauthToken: string
|
| 427 |
+
modelId: string
|
| 428 |
+
messages: OpenAIMessage[]
|
| 429 |
+
isStreaming: boolean
|
| 430 |
+
maxTokens?: number
|
| 431 |
+
tools?: OpenAITool[]
|
| 432 |
+
signal?: AbortSignal
|
| 433 |
+
}): Promise<Response> {
|
| 434 |
+
const compatibility = getCachedCopilotCompatibility(params.modelId)
|
| 435 |
+
if (compatibility?.modelSupported === false) {
|
| 436 |
+
return createCachedCopilotErrorResponse(
|
| 437 |
+
`Copilot model "${params.modelId}" was previously rejected as unsupported.`,
|
| 438 |
+
'model_not_supported',
|
| 439 |
+
)
|
| 440 |
+
}
|
| 441 |
+
if (compatibility?.chatCompletionsSupported === false) {
|
| 442 |
+
return createCachedCopilotErrorResponse(
|
| 443 |
+
`Copilot model "${params.modelId}" does not support the /chat/completions endpoint.`,
|
| 444 |
+
'unsupported_api_for_model',
|
| 445 |
+
)
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
let outputTokenParam = getPreferredCopilotOutputTokenParam(params.modelId)
|
| 449 |
+
let requestBody = buildCopilotChatRequestBody({
|
| 450 |
+
modelId: params.modelId,
|
| 451 |
+
messages: params.messages,
|
| 452 |
+
isStreaming: params.isStreaming,
|
| 453 |
+
maxTokens: params.maxTokens,
|
| 454 |
+
tools: params.tools,
|
| 455 |
+
outputTokenParam,
|
| 456 |
+
})
|
| 457 |
+
|
| 458 |
+
let response = await postCopilotChatCompletion({
|
| 459 |
+
oauthToken: params.oauthToken,
|
| 460 |
+
requestBody,
|
| 461 |
+
signal: params.signal,
|
| 462 |
+
})
|
| 463 |
+
if (response.ok) {
|
| 464 |
+
saveCopilotCompatibility(params.modelId, {
|
| 465 |
+
modelSupported: true,
|
| 466 |
+
chatCompletionsSupported: true,
|
| 467 |
+
outputTokenParam,
|
| 468 |
+
})
|
| 469 |
+
return response
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
const errorInfo = await createCopilotErrorInfo(response)
|
| 473 |
+
if (!params.maxTokens) {
|
| 474 |
+
saveCopilotCompatibilityFromError(params.modelId, errorInfo)
|
| 475 |
+
return response
|
| 476 |
+
}
|
| 477 |
+
const suggestedParam = getSuggestedCopilotOutputTokenParam(
|
| 478 |
+
outputTokenParam,
|
| 479 |
+
errorInfo.message,
|
| 480 |
+
)
|
| 481 |
+
if (!suggestedParam) {
|
| 482 |
+
saveCopilotCompatibilityFromError(params.modelId, errorInfo)
|
| 483 |
+
return response
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
savePreferredCopilotOutputTokenParam(params.modelId, suggestedParam)
|
| 487 |
+
outputTokenParam = suggestedParam
|
| 488 |
+
requestBody = buildCopilotChatRequestBody({
|
| 489 |
+
modelId: params.modelId,
|
| 490 |
+
messages: params.messages,
|
| 491 |
+
isStreaming: params.isStreaming,
|
| 492 |
+
maxTokens: params.maxTokens,
|
| 493 |
+
tools: params.tools,
|
| 494 |
+
outputTokenParam,
|
| 495 |
+
})
|
| 496 |
+
|
| 497 |
+
response = await postCopilotChatCompletion({
|
| 498 |
+
oauthToken: params.oauthToken,
|
| 499 |
+
requestBody,
|
| 500 |
+
signal: params.signal,
|
| 501 |
+
})
|
| 502 |
+
if (response.ok) {
|
| 503 |
+
saveCopilotCompatibility(params.modelId, {
|
| 504 |
+
modelSupported: true,
|
| 505 |
+
chatCompletionsSupported: true,
|
| 506 |
+
outputTokenParam,
|
| 507 |
+
})
|
| 508 |
+
return response
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
saveCopilotCompatibilityFromError(
|
| 512 |
+
params.modelId,
|
| 513 |
+
await createCopilotErrorInfo(response),
|
| 514 |
+
)
|
| 515 |
+
return response
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
type OpenAIMessage = {
|
| 519 |
+
role: 'system' | 'user' | 'assistant' | 'tool'
|
| 520 |
+
content: string | Array<{ type: string; text?: string; image_url?: { url: string } }>
|
| 521 |
+
tool_calls?: Array<{
|
| 522 |
+
id: string
|
| 523 |
+
type: 'function'
|
| 524 |
+
function: { name: string; arguments: string }
|
| 525 |
+
}>
|
| 526 |
+
tool_call_id?: string
|
| 527 |
+
}
|
| 528 |
+
|
| 529 |
+
type OpenAITool = {
|
| 530 |
+
type: 'function'
|
| 531 |
+
function: {
|
| 532 |
+
name: string
|
| 533 |
+
description: string
|
| 534 |
+
parameters: Record<string, unknown>
|
| 535 |
+
}
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
export type AnthropicContentBlock =
|
| 539 |
+
| { type: 'text'; text: string }
|
| 540 |
+
| { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
| 541 |
+
| { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
| 542 |
+
| { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> }
|
| 543 |
+
| { type: 'thinking'; thinking: string }
|
| 544 |
+
| Record<string, unknown>
|
| 545 |
+
|
| 546 |
+
export type AnthropicMessage = {
|
| 547 |
+
role: 'user' | 'assistant'
|
| 548 |
+
content: string | AnthropicContentBlock[]
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
export function convertAnthropicMessagesToOpenAI(
|
| 552 |
+
messages: AnthropicMessage[],
|
| 553 |
+
systemPrompt?: string,
|
| 554 |
+
): OpenAIMessage[] {
|
| 555 |
+
const result: OpenAIMessage[] = []
|
| 556 |
+
|
| 557 |
+
if (systemPrompt) {
|
| 558 |
+
result.push({ role: 'system', content: systemPrompt })
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
for (const msg of messages) {
|
| 562 |
+
if (typeof msg.content === 'string') {
|
| 563 |
+
result.push({ role: msg.role, content: msg.content })
|
| 564 |
+
continue
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
if (msg.role === 'user') {
|
| 568 |
+
const parts: Array<{ type: string; text?: string; image_url?: { url: string } }> = []
|
| 569 |
+
const toolResults: OpenAIMessage[] = []
|
| 570 |
+
|
| 571 |
+
for (const block of msg.content) {
|
| 572 |
+
if (block.type === 'text') {
|
| 573 |
+
parts.push({ type: 'text', text: (block as { type: 'text'; text: string }).text })
|
| 574 |
+
} else if (block.type === 'image') {
|
| 575 |
+
const imgBlock = block as { type: 'image'; source: { type: 'base64'; media_type: string; data: string } }
|
| 576 |
+
parts.push({
|
| 577 |
+
type: 'image_url',
|
| 578 |
+
image_url: { url: `data:${imgBlock.source.media_type};base64,${imgBlock.source.data}` },
|
| 579 |
+
})
|
| 580 |
+
} else if (block.type === 'tool_result') {
|
| 581 |
+
const trBlock = block as { type: 'tool_result'; tool_use_id: string; content: string | Array<{ type: string; text?: string }> }
|
| 582 |
+
let content = ''
|
| 583 |
+
if (typeof trBlock.content === 'string') {
|
| 584 |
+
content = trBlock.content
|
| 585 |
+
} else if (Array.isArray(trBlock.content)) {
|
| 586 |
+
content = trBlock.content
|
| 587 |
+
.filter(c => c.type === 'text')
|
| 588 |
+
.map(c => c.text || '')
|
| 589 |
+
.join('\n')
|
| 590 |
+
}
|
| 591 |
+
toolResults.push({
|
| 592 |
+
role: 'tool',
|
| 593 |
+
content,
|
| 594 |
+
tool_call_id: trBlock.tool_use_id,
|
| 595 |
+
})
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
if (toolResults.length > 0) {
|
| 600 |
+
result.push(...toolResults)
|
| 601 |
+
if (parts.length > 0) {
|
| 602 |
+
result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts })
|
| 603 |
+
}
|
| 604 |
+
} else if (parts.length > 0) {
|
| 605 |
+
result.push({ role: 'user', content: parts.length === 1 && parts[0].type === 'text' ? parts[0].text! : parts })
|
| 606 |
+
}
|
| 607 |
+
} else if (msg.role === 'assistant') {
|
| 608 |
+
const textParts: string[] = []
|
| 609 |
+
const toolCalls: Array<{ id: string; type: 'function'; function: { name: string; arguments: string } }> = []
|
| 610 |
+
|
| 611 |
+
for (const block of msg.content) {
|
| 612 |
+
if (block.type === 'text') {
|
| 613 |
+
textParts.push((block as { type: 'text'; text: string }).text)
|
| 614 |
+
} else if (block.type === 'tool_use') {
|
| 615 |
+
const tuBlock = block as { type: 'tool_use'; id: string; name: string; input: Record<string, unknown> }
|
| 616 |
+
toolCalls.push({
|
| 617 |
+
id: tuBlock.id,
|
| 618 |
+
type: 'function',
|
| 619 |
+
function: {
|
| 620 |
+
name: tuBlock.name,
|
| 621 |
+
arguments: JSON.stringify(tuBlock.input),
|
| 622 |
+
},
|
| 623 |
+
})
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
const assistantMsg: OpenAIMessage = {
|
| 628 |
+
role: 'assistant',
|
| 629 |
+
content: textParts.join('\n') || '',
|
| 630 |
+
}
|
| 631 |
+
if (toolCalls.length > 0) {
|
| 632 |
+
assistantMsg.tool_calls = toolCalls
|
| 633 |
+
}
|
| 634 |
+
result.push(assistantMsg)
|
| 635 |
+
}
|
| 636 |
+
}
|
| 637 |
+
|
| 638 |
+
return result
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
export function convertAnthropicToolsToOpenAI(
|
| 642 |
+
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
|
| 643 |
+
): OpenAITool[] {
|
| 644 |
+
return tools.map(tool => ({
|
| 645 |
+
type: 'function' as const,
|
| 646 |
+
function: {
|
| 647 |
+
name: tool.name,
|
| 648 |
+
description: tool.description || '',
|
| 649 |
+
parameters: tool.input_schema || { type: 'object', properties: {} },
|
| 650 |
+
},
|
| 651 |
+
}))
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
export type CopilotStreamEvent =
|
| 655 |
+
| { type: 'message_start'; message: { id: string; type: 'message'; role: 'assistant'; model: string; content: []; usage: { input_tokens: number; output_tokens: number } } }
|
| 656 |
+
| { type: 'content_block_start'; index: number; content_block: { type: 'text'; text: string } }
|
| 657 |
+
| { type: 'content_block_delta'; index: number; delta: { type: 'text_delta'; text: string } }
|
| 658 |
+
| { type: 'content_block_stop'; index: number }
|
| 659 |
+
| { type: 'message_delta'; delta: { stop_reason: string }; usage: { output_tokens: number } }
|
| 660 |
+
| { type: 'message_stop' }
|
| 661 |
+
|
| 662 |
+
export async function* streamCopilotRequest(
|
| 663 |
+
model: string,
|
| 664 |
+
messages: AnthropicMessage[],
|
| 665 |
+
systemPrompt: string | undefined,
|
| 666 |
+
tools: Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>,
|
| 667 |
+
signal: AbortSignal,
|
| 668 |
+
): AsyncGenerator<CopilotStreamEvent> {
|
| 669 |
+
const provider = getCopilotProvider()
|
| 670 |
+
if (!provider?.oauthToken) {
|
| 671 |
+
throw new Error('GitHub Copilot is not connected. Use /connect to authenticate.')
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
const copilotModelId = getCopilotModelId(model)
|
| 675 |
+
const openaiMessages = convertAnthropicMessagesToOpenAI(messages, systemPrompt)
|
| 676 |
+
const openaiTools = tools.length > 0 ? convertAnthropicToolsToOpenAI(tools) : undefined
|
| 677 |
+
const response = await sendCopilotChatCompletion({
|
| 678 |
+
oauthToken: provider.oauthToken,
|
| 679 |
+
modelId: copilotModelId,
|
| 680 |
+
messages: openaiMessages,
|
| 681 |
+
isStreaming: true,
|
| 682 |
+
maxTokens: 16384,
|
| 683 |
+
tools: openaiTools,
|
| 684 |
+
signal,
|
| 685 |
+
})
|
| 686 |
+
|
| 687 |
+
if (!response.ok) {
|
| 688 |
+
const errorText = await response.text().catch(() => 'unknown error')
|
| 689 |
+
throw new Error(`Copilot API error (${response.status}): ${errorText}`)
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
if (!response.body) {
|
| 693 |
+
throw new Error('No response body from Copilot API')
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
const messageId = `msg_copilot_${Date.now()}`
|
| 697 |
+
let contentIndex = 0
|
| 698 |
+
let hasStartedContent = false
|
| 699 |
+
let currentToolCallIndex = -1
|
| 700 |
+
const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
|
| 701 |
+
let totalOutputTokens = 0
|
| 702 |
+
|
| 703 |
+
yield {
|
| 704 |
+
type: 'message_start',
|
| 705 |
+
message: {
|
| 706 |
+
id: messageId,
|
| 707 |
+
type: 'message',
|
| 708 |
+
role: 'assistant',
|
| 709 |
+
model: copilotModelId,
|
| 710 |
+
content: [],
|
| 711 |
+
usage: { input_tokens: 0, output_tokens: 0 },
|
| 712 |
+
},
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
const reader = response.body.getReader()
|
| 716 |
+
const decoder = new TextDecoder()
|
| 717 |
+
let buffer = ''
|
| 718 |
+
|
| 719 |
+
try {
|
| 720 |
+
while (true) {
|
| 721 |
+
const { done, value } = await reader.read()
|
| 722 |
+
if (done) break
|
| 723 |
+
|
| 724 |
+
buffer += decoder.decode(value, { stream: true })
|
| 725 |
+
|
| 726 |
+
const lines = buffer.split('\n')
|
| 727 |
+
buffer = lines.pop() || ''
|
| 728 |
+
|
| 729 |
+
for (const line of lines) {
|
| 730 |
+
if (!line.startsWith('data: ')) continue
|
| 731 |
+
const data = line.slice(6).trim()
|
| 732 |
+
if (data === '[DONE]') {
|
| 733 |
+
if (hasStartedContent) {
|
| 734 |
+
yield { type: 'content_block_stop', index: contentIndex - 1 }
|
| 735 |
+
}
|
| 736 |
+
for (const [idx, tc] of toolCalls) {
|
| 737 |
+
yield {
|
| 738 |
+
type: 'content_block_stop',
|
| 739 |
+
index: contentIndex + idx,
|
| 740 |
+
}
|
| 741 |
+
}
|
| 742 |
+
yield {
|
| 743 |
+
type: 'message_delta',
|
| 744 |
+
delta: { stop_reason: toolCalls.size > 0 ? 'tool_use' : 'end_turn' },
|
| 745 |
+
usage: { output_tokens: totalOutputTokens },
|
| 746 |
+
}
|
| 747 |
+
yield { type: 'message_stop' }
|
| 748 |
+
return
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
let chunk: {
|
| 752 |
+
choices?: Array<{
|
| 753 |
+
delta?: {
|
| 754 |
+
content?: string | null
|
| 755 |
+
tool_calls?: Array<{
|
| 756 |
+
index: number
|
| 757 |
+
id?: string
|
| 758 |
+
function?: { name?: string; arguments?: string }
|
| 759 |
+
}>
|
| 760 |
+
role?: string
|
| 761 |
+
}
|
| 762 |
+
finish_reason?: string | null
|
| 763 |
+
}>
|
| 764 |
+
usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number }
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
try {
|
| 768 |
+
chunk = JSON.parse(data)
|
| 769 |
+
} catch {
|
| 770 |
+
continue
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
if (chunk.usage?.completion_tokens) {
|
| 774 |
+
totalOutputTokens = chunk.usage.completion_tokens
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
const choice = chunk.choices?.[0]
|
| 778 |
+
if (!choice?.delta) continue
|
| 779 |
+
|
| 780 |
+
const delta = choice.delta
|
| 781 |
+
|
| 782 |
+
if (delta.content != null && delta.content !== '') {
|
| 783 |
+
if (!hasStartedContent) {
|
| 784 |
+
hasStartedContent = true
|
| 785 |
+
yield {
|
| 786 |
+
type: 'content_block_start',
|
| 787 |
+
index: contentIndex,
|
| 788 |
+
content_block: { type: 'text', text: '' },
|
| 789 |
+
}
|
| 790 |
+
}
|
| 791 |
+
yield {
|
| 792 |
+
type: 'content_block_delta',
|
| 793 |
+
index: contentIndex,
|
| 794 |
+
delta: { type: 'text_delta', text: delta.content },
|
| 795 |
+
}
|
| 796 |
+
}
|
| 797 |
+
|
| 798 |
+
if (delta.tool_calls) {
|
| 799 |
+
for (const tc of delta.tool_calls) {
|
| 800 |
+
if (tc.id) {
|
| 801 |
+
if (hasStartedContent && currentToolCallIndex === -1) {
|
| 802 |
+
yield { type: 'content_block_stop', index: contentIndex }
|
| 803 |
+
contentIndex++
|
| 804 |
+
hasStartedContent = false
|
| 805 |
+
}
|
| 806 |
+
currentToolCallIndex = tc.index
|
| 807 |
+
toolCalls.set(tc.index, {
|
| 808 |
+
id: tc.id,
|
| 809 |
+
name: tc.function?.name || '',
|
| 810 |
+
arguments: tc.function?.arguments || '',
|
| 811 |
+
})
|
| 812 |
+
const toolBlockIndex = hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
|
| 813 |
+
yield {
|
| 814 |
+
type: 'content_block_start' as const,
|
| 815 |
+
index: toolBlockIndex,
|
| 816 |
+
content_block: {
|
| 817 |
+
type: 'text',
|
| 818 |
+
text: '',
|
| 819 |
+
} as any,
|
| 820 |
+
}
|
| 821 |
+
} else if (tc.function?.arguments) {
|
| 822 |
+
const existing = toolCalls.get(tc.index)
|
| 823 |
+
if (existing) {
|
| 824 |
+
existing.arguments += tc.function.arguments
|
| 825 |
+
}
|
| 826 |
+
}
|
| 827 |
+
}
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
if (choice.finish_reason) {
|
| 831 |
+
if (hasStartedContent) {
|
| 832 |
+
yield { type: 'content_block_stop', index: contentIndex }
|
| 833 |
+
}
|
| 834 |
+
yield {
|
| 835 |
+
type: 'message_delta',
|
| 836 |
+
delta: { stop_reason: choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn' },
|
| 837 |
+
usage: { output_tokens: totalOutputTokens },
|
| 838 |
+
}
|
| 839 |
+
yield { type: 'message_stop' }
|
| 840 |
+
return
|
| 841 |
+
}
|
| 842 |
+
}
|
| 843 |
+
}
|
| 844 |
+
} finally {
|
| 845 |
+
reader.releaseLock()
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
+
yield {
|
| 849 |
+
type: 'message_delta',
|
| 850 |
+
delta: { stop_reason: 'end_turn' },
|
| 851 |
+
usage: { output_tokens: totalOutputTokens },
|
| 852 |
+
}
|
| 853 |
+
yield { type: 'message_stop' }
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
export function createCopilotFetchOverride(
|
| 857 |
+
model: string,
|
| 858 |
+
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
| 859 |
+
const provider = getCopilotProvider()
|
| 860 |
+
if (!provider?.oauthToken) {
|
| 861 |
+
throw new Error('GitHub Copilot is not connected')
|
| 862 |
+
}
|
| 863 |
+
|
| 864 |
+
const copilotModelId = getCopilotModelId(model)
|
| 865 |
+
|
| 866 |
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
| 867 |
+
const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url
|
| 868 |
+
|
| 869 |
+
if (!url.includes('/messages') && !url.includes('/v1/')) {
|
| 870 |
+
return fetch(input, init)
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
if (url.includes('/count_tokens') || url.includes('/models')) {
|
| 874 |
+
return new Response(JSON.stringify({ input_tokens: 0 }), {
|
| 875 |
+
status: 200,
|
| 876 |
+
headers: { 'Content-Type': 'application/json' },
|
| 877 |
+
})
|
| 878 |
+
}
|
| 879 |
+
|
| 880 |
+
let anthropicBody: Record<string, unknown> = {}
|
| 881 |
+
if (init?.body) {
|
| 882 |
+
try {
|
| 883 |
+
anthropicBody = JSON.parse(typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer))
|
| 884 |
+
} catch {
|
| 885 |
+
return fetch(input, init)
|
| 886 |
+
}
|
| 887 |
+
}
|
| 888 |
+
|
| 889 |
+
const systemBlocks = anthropicBody.system as Array<{ type: string; text: string }> | string | undefined
|
| 890 |
+
let systemPrompt = ''
|
| 891 |
+
if (typeof systemBlocks === 'string') {
|
| 892 |
+
systemPrompt = systemBlocks
|
| 893 |
+
} else if (Array.isArray(systemBlocks)) {
|
| 894 |
+
systemPrompt = systemBlocks
|
| 895 |
+
.filter(b => b.type === 'text')
|
| 896 |
+
.map(b => b.text)
|
| 897 |
+
.join('\n\n')
|
| 898 |
+
}
|
| 899 |
+
|
| 900 |
+
const anthropicMessages = (anthropicBody.messages || []) as AnthropicMessage[]
|
| 901 |
+
const openaiMessages = convertAnthropicMessagesToOpenAI(anthropicMessages, systemPrompt)
|
| 902 |
+
|
| 903 |
+
const anthropicTools = (anthropicBody.tools || []) as Array<{ name: string; description?: string; input_schema?: Record<string, unknown> }>
|
| 904 |
+
const openaiTools = anthropicTools.length > 0 ? convertAnthropicToolsToOpenAI(anthropicTools) : undefined
|
| 905 |
+
|
| 906 |
+
const isStreaming = anthropicBody.stream === true
|
| 907 |
+
const maxTokens =
|
| 908 |
+
typeof anthropicBody.max_tokens === 'number'
|
| 909 |
+
? anthropicBody.max_tokens
|
| 910 |
+
: undefined
|
| 911 |
+
|
| 912 |
+
const copilotResponse = await sendCopilotChatCompletion({
|
| 913 |
+
oauthToken: provider.oauthToken,
|
| 914 |
+
modelId: copilotModelId,
|
| 915 |
+
messages: openaiMessages,
|
| 916 |
+
isStreaming,
|
| 917 |
+
maxTokens,
|
| 918 |
+
tools: openaiTools,
|
| 919 |
+
signal: init?.signal,
|
| 920 |
+
})
|
| 921 |
+
|
| 922 |
+
if (!copilotResponse.ok) {
|
| 923 |
+
return copilotResponse
|
| 924 |
+
}
|
| 925 |
+
|
| 926 |
+
if (!isStreaming) {
|
| 927 |
+
const data = await copilotResponse.json() as {
|
| 928 |
+
id: string
|
| 929 |
+
choices: Array<{
|
| 930 |
+
message: {
|
| 931 |
+
role: string
|
| 932 |
+
content: string | null
|
| 933 |
+
tool_calls?: Array<{
|
| 934 |
+
id: string
|
| 935 |
+
function: { name: string; arguments: string }
|
| 936 |
+
}>
|
| 937 |
+
}
|
| 938 |
+
finish_reason: string
|
| 939 |
+
}>
|
| 940 |
+
usage?: { prompt_tokens: number; completion_tokens: number }
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
const choice = data.choices[0]
|
| 944 |
+
const anthropicContent: Array<{
|
| 945 |
+
type: string
|
| 946 |
+
text?: string
|
| 947 |
+
id?: string
|
| 948 |
+
name?: string
|
| 949 |
+
input?: unknown
|
| 950 |
+
}> = []
|
| 951 |
+
|
| 952 |
+
if (choice?.message?.content) {
|
| 953 |
+
anthropicContent.push({ type: 'text', text: choice.message.content })
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
if (choice?.message?.tool_calls) {
|
| 957 |
+
for (const tc of choice.message.tool_calls) {
|
| 958 |
+
anthropicContent.push({
|
| 959 |
+
type: 'tool_use',
|
| 960 |
+
id: tc.id,
|
| 961 |
+
name: tc.function.name,
|
| 962 |
+
input: JSON.parse(tc.function.arguments || '{}'),
|
| 963 |
+
})
|
| 964 |
+
}
|
| 965 |
+
}
|
| 966 |
+
|
| 967 |
+
const anthropicResponse = {
|
| 968 |
+
id: data.id || `msg_copilot_${Date.now()}`,
|
| 969 |
+
type: 'message',
|
| 970 |
+
role: 'assistant',
|
| 971 |
+
content: anthropicContent,
|
| 972 |
+
model: copilotModelId,
|
| 973 |
+
stop_reason: choice?.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn',
|
| 974 |
+
usage: {
|
| 975 |
+
input_tokens: data.usage?.prompt_tokens || 0,
|
| 976 |
+
output_tokens: data.usage?.completion_tokens || 0,
|
| 977 |
+
},
|
| 978 |
+
}
|
| 979 |
+
|
| 980 |
+
return new Response(JSON.stringify(anthropicResponse), {
|
| 981 |
+
status: 200,
|
| 982 |
+
headers: { 'Content-Type': 'application/json' },
|
| 983 |
+
})
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
const transformStream = convertOpenAIStreamToAnthropic(copilotResponse.body!, copilotModelId)
|
| 987 |
+
|
| 988 |
+
return new Response(transformStream, {
|
| 989 |
+
status: 200,
|
| 990 |
+
headers: {
|
| 991 |
+
'Content-Type': 'text/event-stream',
|
| 992 |
+
'Cache-Control': 'no-cache',
|
| 993 |
+
Connection: 'keep-alive',
|
| 994 |
+
},
|
| 995 |
+
})
|
| 996 |
+
}
|
| 997 |
+
}
|
| 998 |
+
|
| 999 |
+
export function convertOpenAIStreamToAnthropic(
|
| 1000 |
+
openaiStream: ReadableStream,
|
| 1001 |
+
model: string,
|
| 1002 |
+
): ReadableStream<Uint8Array> {
|
| 1003 |
+
const encoder = new TextEncoder()
|
| 1004 |
+
const decoder = new TextDecoder()
|
| 1005 |
+
|
| 1006 |
+
let messageId = `msg_${Date.now()}`
|
| 1007 |
+
let contentIndex = 0
|
| 1008 |
+
let hasStartedContent = false
|
| 1009 |
+
let currentToolCallIndex = -1
|
| 1010 |
+
const toolCalls: Map<number, { id: string; name: string; arguments: string }> = new Map()
|
| 1011 |
+
let totalOutputTokens = 0
|
| 1012 |
+
|
| 1013 |
+
return new ReadableStream({
|
| 1014 |
+
async start(controller) {
|
| 1015 |
+
const reader = openaiStream.getReader()
|
| 1016 |
+
let buffer = ''
|
| 1017 |
+
|
| 1018 |
+
try {
|
| 1019 |
+
while (true) {
|
| 1020 |
+
const { done, value } = await reader.read()
|
| 1021 |
+
if (done) break
|
| 1022 |
+
|
| 1023 |
+
buffer += decoder.decode(value, { stream: true })
|
| 1024 |
+
|
| 1025 |
+
const lines = buffer.split('\n')
|
| 1026 |
+
buffer = lines.pop() || ''
|
| 1027 |
+
|
| 1028 |
+
for (const line of lines) {
|
| 1029 |
+
if (!line.startsWith('data: ')) continue
|
| 1030 |
+
const data = line.slice(6).trim()
|
| 1031 |
+
if (data === '[DONE]') {
|
| 1032 |
+
if (hasStartedContent) {
|
| 1033 |
+
controller.enqueue(encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex - 1}}\n\n`))
|
| 1034 |
+
}
|
| 1035 |
+
for (const [idx, tc] of toolCalls) {
|
| 1036 |
+
controller.enqueue(
|
| 1037 |
+
encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex + idx}}\n\n`),
|
| 1038 |
+
)
|
| 1039 |
+
}
|
| 1040 |
+
controller.enqueue(
|
| 1041 |
+
encoder.encode(
|
| 1042 |
+
`event: message_delta\ndata: {"delta":{"stop_reason":"${toolCalls.size > 0 ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
|
| 1043 |
+
),
|
| 1044 |
+
)
|
| 1045 |
+
controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
|
| 1046 |
+
return
|
| 1047 |
+
}
|
| 1048 |
+
|
| 1049 |
+
let chunk: {
|
| 1050 |
+
choices?: Array<{
|
| 1051 |
+
delta?: {
|
| 1052 |
+
content?: string | null
|
| 1053 |
+
tool_calls?: Array<{
|
| 1054 |
+
index: number
|
| 1055 |
+
id?: string
|
| 1056 |
+
function?: { name?: string; arguments?: string }
|
| 1057 |
+
}>
|
| 1058 |
+
role?: string
|
| 1059 |
+
}
|
| 1060 |
+
finish_reason?: string | null
|
| 1061 |
+
}>
|
| 1062 |
+
usage?: { completion_tokens?: number; prompt_tokens?: number; total_tokens?: number }
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
try {
|
| 1066 |
+
chunk = JSON.parse(data)
|
| 1067 |
+
} catch {
|
| 1068 |
+
continue
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
if (chunk.usage?.completion_tokens) {
|
| 1072 |
+
totalOutputTokens = chunk.usage.completion_tokens
|
| 1073 |
+
}
|
| 1074 |
+
|
| 1075 |
+
const choice = chunk.choices?.[0]
|
| 1076 |
+
if (!choice?.delta) continue
|
| 1077 |
+
|
| 1078 |
+
const delta = choice.delta
|
| 1079 |
+
|
| 1080 |
+
if (delta.content != null && delta.content !== '') {
|
| 1081 |
+
if (!hasStartedContent) {
|
| 1082 |
+
hasStartedContent = true
|
| 1083 |
+
controller.enqueue(
|
| 1084 |
+
encoder.encode(
|
| 1085 |
+
`event: content_block_start\ndata: {"index":${contentIndex},"content_block":{"type":"text","text":""}}\n\n`,
|
| 1086 |
+
),
|
| 1087 |
+
)
|
| 1088 |
+
}
|
| 1089 |
+
controller.enqueue(
|
| 1090 |
+
encoder.encode(
|
| 1091 |
+
`event: content_block_delta\ndata: {"index":${contentIndex},"delta":{"type":"text_delta","text":"${JSON.stringify(delta.content).slice(1, -1)}"}}\n\n`,
|
| 1092 |
+
),
|
| 1093 |
+
)
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
if (delta.tool_calls) {
|
| 1097 |
+
for (const tc of delta.tool_calls) {
|
| 1098 |
+
if (tc.id) {
|
| 1099 |
+
if (hasStartedContent && currentToolCallIndex === -1) {
|
| 1100 |
+
controller.enqueue(
|
| 1101 |
+
encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
|
| 1102 |
+
)
|
| 1103 |
+
contentIndex++
|
| 1104 |
+
hasStartedContent = false
|
| 1105 |
+
}
|
| 1106 |
+
currentToolCallIndex = tc.index
|
| 1107 |
+
toolCalls.set(tc.index, {
|
| 1108 |
+
id: tc.id,
|
| 1109 |
+
name: tc.function?.name || '',
|
| 1110 |
+
arguments: tc.function?.arguments || '',
|
| 1111 |
+
})
|
| 1112 |
+
const toolBlockIndex =
|
| 1113 |
+
hasStartedContent ? contentIndex + 1 + tc.index : contentIndex + tc.index
|
| 1114 |
+
controller.enqueue(
|
| 1115 |
+
encoder.encode(
|
| 1116 |
+
`event: content_block_start\ndata: {"index":${toolBlockIndex},"content_block":{"type":"text","text":""}}\n\n`,
|
| 1117 |
+
),
|
| 1118 |
+
)
|
| 1119 |
+
} else if (tc.function?.arguments) {
|
| 1120 |
+
const existing = toolCalls.get(tc.index)
|
| 1121 |
+
if (existing) {
|
| 1122 |
+
existing.arguments += tc.function.arguments
|
| 1123 |
+
}
|
| 1124 |
+
}
|
| 1125 |
+
}
|
| 1126 |
+
}
|
| 1127 |
+
|
| 1128 |
+
if (choice.finish_reason) {
|
| 1129 |
+
if (hasStartedContent) {
|
| 1130 |
+
controller.enqueue(
|
| 1131 |
+
encoder.encode(`event: content_block_stop\ndata: {"index":${contentIndex}}\n\n`),
|
| 1132 |
+
)
|
| 1133 |
+
}
|
| 1134 |
+
controller.enqueue(
|
| 1135 |
+
encoder.encode(
|
| 1136 |
+
`event: message_delta\ndata: {"delta":{"stop_reason":"${choice.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn'}"},"usage":{"output_tokens":${totalOutputTokens}}}\n\n`,
|
| 1137 |
+
),
|
| 1138 |
+
)
|
| 1139 |
+
controller.enqueue(encoder.encode('event: message_stop\ndata: {}\n\n'))
|
| 1140 |
+
return
|
| 1141 |
+
}
|
| 1142 |
+
}
|
| 1143 |
+
}
|
| 1144 |
+
} finally {
|
| 1145 |
+
reader.releaseLock()
|
| 1146 |
+
controller.close()
|
| 1147 |
+
}
|
| 1148 |
+
},
|
| 1149 |
+
})
|
| 1150 |
+
}
|
src/services/api/customOpenAIClient.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { getGlobalConfig, type ConnectedProviderInfo } from '../../utils/config.js'
|
| 2 |
+
import {
|
| 3 |
+
convertAnthropicMessagesToOpenAI,
|
| 4 |
+
convertAnthropicToolsToOpenAI,
|
| 5 |
+
convertOpenAIStreamToAnthropic,
|
| 6 |
+
type AnthropicMessage,
|
| 7 |
+
} from './copilotClient.js'
|
| 8 |
+
|
| 9 |
+
const PREFIX = 'custom-openai:'
|
| 10 |
+
|
| 11 |
+
export function isCustomOpenAIModel(model: string | undefined): boolean {
|
| 12 |
+
return !!model?.startsWith(PREFIX)
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function getCustomOpenAIModelId(model: string): string {
|
| 16 |
+
const rest = model.slice(PREFIX.length).trim()
|
| 17 |
+
if (rest) {
|
| 18 |
+
return rest
|
| 19 |
+
}
|
| 20 |
+
const p = getGlobalConfig().connectedProviders?.['custom-openai']
|
| 21 |
+
return p?.defaultModel || 'gpt-4o-mini'
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
export function getCustomOpenAIProvider(): ConnectedProviderInfo | undefined {
|
| 25 |
+
return getGlobalConfig().connectedProviders?.['custom-openai']
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function isCustomOpenAIConnected(): boolean {
|
| 29 |
+
const c = getGlobalConfig()
|
| 30 |
+
return c.activeProvider === 'custom-openai' && !!c.connectedProviders?.['custom-openai']?.baseUrl
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
function normalizeBaseUrl(url: string): string {
|
| 34 |
+
return url.replace(/\/$/, '')
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
/** Supports `https://host` or `https://host/v1` style bases. */
|
| 38 |
+
function chatCompletionsUrl(base: string): string {
|
| 39 |
+
const b = normalizeBaseUrl(base)
|
| 40 |
+
if (b.endsWith('/v1')) {
|
| 41 |
+
return `${b}/chat/completions`
|
| 42 |
+
}
|
| 43 |
+
return `${b}/v1/chat/completions`
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* OpenAI-compatible `/v1/chat/completions` bridge (same protocol as GitHub Copilot path).
|
| 48 |
+
*/
|
| 49 |
+
export function createCustomOpenAIFetchOverride(
|
| 50 |
+
model: string,
|
| 51 |
+
): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response> {
|
| 52 |
+
const provider = getCustomOpenAIProvider()
|
| 53 |
+
if (!provider?.baseUrl) {
|
| 54 |
+
throw new Error('Custom OpenAI-compatible API is not configured')
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
const openaiModelId = getCustomOpenAIModelId(model)
|
| 58 |
+
const endpoint = chatCompletionsUrl(provider.baseUrl)
|
| 59 |
+
|
| 60 |
+
return async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
| 61 |
+
const url = input instanceof URL ? input.href : typeof input === 'string' ? input : input.url
|
| 62 |
+
|
| 63 |
+
if (!url.includes('/messages') && !url.includes('/v1/')) {
|
| 64 |
+
return fetch(input, init)
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (url.includes('/count_tokens') || url.includes('/models')) {
|
| 68 |
+
return new Response(JSON.stringify({ input_tokens: 0 }), {
|
| 69 |
+
status: 200,
|
| 70 |
+
headers: { 'Content-Type': 'application/json' },
|
| 71 |
+
})
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
let anthropicBody: Record<string, unknown> = {}
|
| 75 |
+
if (init?.body) {
|
| 76 |
+
try {
|
| 77 |
+
anthropicBody = JSON.parse(
|
| 78 |
+
typeof init.body === 'string' ? init.body : new TextDecoder().decode(init.body as ArrayBuffer),
|
| 79 |
+
)
|
| 80 |
+
} catch {
|
| 81 |
+
return fetch(input, init)
|
| 82 |
+
}
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
const systemBlocks = anthropicBody.system as
|
| 86 |
+
| Array<{ type: string; text: string }>
|
| 87 |
+
| string
|
| 88 |
+
| undefined
|
| 89 |
+
let systemPrompt = ''
|
| 90 |
+
if (typeof systemBlocks === 'string') {
|
| 91 |
+
systemPrompt = systemBlocks
|
| 92 |
+
} else if (Array.isArray(systemBlocks)) {
|
| 93 |
+
systemPrompt = systemBlocks
|
| 94 |
+
.filter(b => b.type === 'text')
|
| 95 |
+
.map(b => b.text)
|
| 96 |
+
.join('\n\n')
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
const anthropicMessages = (anthropicBody.messages || []) as AnthropicMessage[]
|
| 100 |
+
const openaiMessages = convertAnthropicMessagesToOpenAI(anthropicMessages, systemPrompt)
|
| 101 |
+
|
| 102 |
+
const anthropicTools = (anthropicBody.tools || []) as Array<{
|
| 103 |
+
name: string
|
| 104 |
+
description?: string
|
| 105 |
+
input_schema?: Record<string, unknown>
|
| 106 |
+
}>
|
| 107 |
+
const openaiTools = anthropicTools.length > 0 ? convertAnthropicToolsToOpenAI(anthropicTools) : undefined
|
| 108 |
+
|
| 109 |
+
const isStreaming = anthropicBody.stream === true
|
| 110 |
+
|
| 111 |
+
const requestBody: Record<string, unknown> = {
|
| 112 |
+
model: openaiModelId,
|
| 113 |
+
messages: openaiMessages,
|
| 114 |
+
stream: isStreaming,
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
if (anthropicBody.max_tokens) {
|
| 118 |
+
requestBody.max_tokens = anthropicBody.max_tokens
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (openaiTools && openaiTools.length > 0) {
|
| 122 |
+
requestBody.tools = openaiTools
|
| 123 |
+
requestBody.tool_choice = 'auto'
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
const headers: Record<string, string> = {
|
| 127 |
+
'Content-Type': 'application/json',
|
| 128 |
+
'User-Agent': 'claude-code/2.1.88',
|
| 129 |
+
}
|
| 130 |
+
if (provider.apiKey) {
|
| 131 |
+
headers.Authorization = `Bearer ${provider.apiKey}`
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const openaiResponse = await fetch(endpoint, {
|
| 135 |
+
method: 'POST',
|
| 136 |
+
headers,
|
| 137 |
+
body: JSON.stringify(requestBody),
|
| 138 |
+
signal: init?.signal,
|
| 139 |
+
})
|
| 140 |
+
|
| 141 |
+
if (!openaiResponse.ok) {
|
| 142 |
+
return openaiResponse
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
if (!isStreaming) {
|
| 146 |
+
const data = (await openaiResponse.json()) as {
|
| 147 |
+
id: string
|
| 148 |
+
choices: Array<{
|
| 149 |
+
message: {
|
| 150 |
+
role: string
|
| 151 |
+
content: string | null
|
| 152 |
+
tool_calls?: Array<{
|
| 153 |
+
id: string
|
| 154 |
+
function: { name: string; arguments: string }
|
| 155 |
+
}>
|
| 156 |
+
}
|
| 157 |
+
finish_reason: string
|
| 158 |
+
}>
|
| 159 |
+
usage?: { prompt_tokens: number; completion_tokens: number }
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const choice = data.choices[0]
|
| 163 |
+
const anthropicContent: Array<{
|
| 164 |
+
type: string
|
| 165 |
+
text?: string
|
| 166 |
+
id?: string
|
| 167 |
+
name?: string
|
| 168 |
+
input?: unknown
|
| 169 |
+
}> = []
|
| 170 |
+
|
| 171 |
+
if (choice?.message?.content) {
|
| 172 |
+
anthropicContent.push({ type: 'text', text: choice.message.content })
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
if (choice?.message?.tool_calls) {
|
| 176 |
+
for (const tc of choice.message.tool_calls) {
|
| 177 |
+
anthropicContent.push({
|
| 178 |
+
type: 'tool_use',
|
| 179 |
+
id: tc.id,
|
| 180 |
+
name: tc.function.name,
|
| 181 |
+
input: JSON.parse(tc.function.arguments || '{}'),
|
| 182 |
+
})
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
const anthropicResponse = {
|
| 187 |
+
id: data.id || `msg_custom_openai_${Date.now()}`,
|
| 188 |
+
type: 'message',
|
| 189 |
+
role: 'assistant',
|
| 190 |
+
content: anthropicContent,
|
| 191 |
+
model: openaiModelId,
|
| 192 |
+
stop_reason: choice?.finish_reason === 'tool_calls' ? 'tool_use' : 'end_turn',
|
| 193 |
+
usage: {
|
| 194 |
+
input_tokens: data.usage?.prompt_tokens || 0,
|
| 195 |
+
output_tokens: data.usage?.completion_tokens || 0,
|
| 196 |
+
},
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
return new Response(JSON.stringify(anthropicResponse), {
|
| 200 |
+
status: 200,
|
| 201 |
+
headers: { 'Content-Type': 'application/json' },
|
| 202 |
+
})
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
if (!openaiResponse.body) {
|
| 206 |
+
return openaiResponse
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
const transformStream = convertOpenAIStreamToAnthropic(openaiResponse.body, openaiModelId)
|
| 210 |
+
|
| 211 |
+
return new Response(transformStream, {
|
| 212 |
+
status: 200,
|
| 213 |
+
headers: {
|
| 214 |
+
'Content-Type': 'text/event-stream',
|
| 215 |
+
'Cache-Control': 'no-cache',
|
| 216 |
+
Connection: 'keep-alive',
|
| 217 |
+
},
|
| 218 |
+
})
|
| 219 |
+
}
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
function modelsListUrlFromOpenAIBase(base: string): string {
|
| 223 |
+
const b = normalizeBaseUrl(base)
|
| 224 |
+
if (b.endsWith('/v1')) {
|
| 225 |
+
return `${b}/models`
|
| 226 |
+
}
|
| 227 |
+
return `${b}/v1/models`
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
function parseOpenAIStyleModelList(json: unknown): string[] {
|
| 231 |
+
if (!json || typeof json !== 'object') {
|
| 232 |
+
return []
|
| 233 |
+
}
|
| 234 |
+
const o = json as Record<string, unknown>
|
| 235 |
+
const data = o.data
|
| 236 |
+
if (Array.isArray(data)) {
|
| 237 |
+
const ids: string[] = []
|
| 238 |
+
for (const item of data) {
|
| 239 |
+
if (item && typeof item === 'object' && 'id' in item && typeof (item as { id: unknown }).id === 'string') {
|
| 240 |
+
ids.push((item as { id: string }).id)
|
| 241 |
+
}
|
| 242 |
+
}
|
| 243 |
+
return [...new Set(ids.filter(Boolean))]
|
| 244 |
+
}
|
| 245 |
+
const models = o.models
|
| 246 |
+
if (Array.isArray(models)) {
|
| 247 |
+
const ids: string[] = []
|
| 248 |
+
for (const item of models) {
|
| 249 |
+
if (typeof item === 'string') {
|
| 250 |
+
ids.push(item)
|
| 251 |
+
} else if (item && typeof item === 'object' && 'id' in item && typeof (item as { id: unknown }).id === 'string') {
|
| 252 |
+
ids.push((item as { id: string }).id)
|
| 253 |
+
}
|
| 254 |
+
}
|
| 255 |
+
return [...new Set(ids.filter(Boolean))]
|
| 256 |
+
}
|
| 257 |
+
return []
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
/**
|
| 261 |
+
* GET /v1/models (OpenAI-compatible).
|
| 262 |
+
*/
|
| 263 |
+
export async function fetchOpenAICompatibleModelIds(
|
| 264 |
+
baseUrl: string,
|
| 265 |
+
apiKey?: string,
|
| 266 |
+
): Promise<string[]> {
|
| 267 |
+
const url = modelsListUrlFromOpenAIBase(baseUrl)
|
| 268 |
+
const headers: Record<string, string> = {}
|
| 269 |
+
if (apiKey) {
|
| 270 |
+
headers.Authorization = `Bearer ${apiKey}`
|
| 271 |
+
}
|
| 272 |
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) })
|
| 273 |
+
if (!res.ok) {
|
| 274 |
+
const body = await res.text().catch(() => '')
|
| 275 |
+
throw new Error(`OpenAI-compatible /v1/models failed (${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
|
| 276 |
+
}
|
| 277 |
+
const json: unknown = await res.json()
|
| 278 |
+
const ids = parseOpenAIStyleModelList(json)
|
| 279 |
+
return ids
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
/**
|
| 283 |
+
* GET /v1/models (Anthropic API).
|
| 284 |
+
*/
|
| 285 |
+
export async function fetchAnthropicCompatibleModelIds(
|
| 286 |
+
baseUrl: string,
|
| 287 |
+
apiKey?: string,
|
| 288 |
+
): Promise<string[]> {
|
| 289 |
+
let root = baseUrl.replace(/\/$/, '')
|
| 290 |
+
if (root.endsWith('/v1')) {
|
| 291 |
+
root = root.slice(0, -3)
|
| 292 |
+
}
|
| 293 |
+
const url = `${root}/v1/models`
|
| 294 |
+
const headers: Record<string, string> = {
|
| 295 |
+
'anthropic-version': '2023-06-01',
|
| 296 |
+
}
|
| 297 |
+
if (apiKey) {
|
| 298 |
+
headers['x-api-key'] = apiKey
|
| 299 |
+
}
|
| 300 |
+
const res = await fetch(url, { headers, signal: AbortSignal.timeout(20_000) })
|
| 301 |
+
if (!res.ok) {
|
| 302 |
+
const body = await res.text().catch(() => '')
|
| 303 |
+
throw new Error(`Anthropic /v1/models failed (${res.status})${body ? `: ${body.slice(0, 200)}` : ''}`)
|
| 304 |
+
}
|
| 305 |
+
const json: unknown = await res.json()
|
| 306 |
+
const ids = parseOpenAIStyleModelList(json)
|
| 307 |
+
return ids
|
| 308 |
+
}
|
src/services/telegram/TelegramService.ts
CHANGED
|
@@ -1,22 +1,44 @@
|
|
| 1 |
-
import { logForDebugging } from '../../utils/debug.js'
|
| 2 |
import { getTelegramRuntimeConfig } from './telegramConfig.js'
|
| 3 |
import type {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
TelegramGetMeResponse,
|
| 5 |
TelegramGetUpdatesResponse,
|
| 6 |
TelegramInboundEvent,
|
|
|
|
| 7 |
TelegramRuntimeConfig,
|
| 8 |
TelegramSendMessageResponse,
|
|
|
|
| 9 |
TelegramServiceState,
|
| 10 |
TelegramUpdate,
|
| 11 |
} from './telegramTypes.js'
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
type Listener = () => void
|
| 14 |
type InboundListener = (event: TelegramInboundEvent) => void
|
|
|
|
| 15 |
|
| 16 |
const TELEGRAM_API_BASE = 'https://api.telegram.org'
|
| 17 |
const MAX_TELEGRAM_MESSAGE_LENGTH = 4000
|
| 18 |
const POLL_TIMEOUT_SECONDS = 25
|
| 19 |
const RETRY_DELAY_MS = 3000
|
|
|
|
|
|
|
| 20 |
|
| 21 |
function sleep(ms: number): Promise<void> {
|
| 22 |
return new Promise(resolve => setTimeout(resolve, ms))
|
|
@@ -49,9 +71,39 @@ function hasSameConfig(
|
|
| 49 |
)
|
| 50 |
}
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
class TelegramService {
|
| 53 |
private listeners = new Set<Listener>()
|
| 54 |
private inboundListeners = new Set<InboundListener>()
|
|
|
|
| 55 |
private state: TelegramServiceState = { status: 'stopped' }
|
| 56 |
private config?: TelegramRuntimeConfig
|
| 57 |
private abortController: AbortController | null = null
|
|
@@ -72,6 +124,13 @@ class TelegramService {
|
|
| 72 |
}
|
| 73 |
}
|
| 74 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
getStateSnapshot = (): TelegramServiceState => this.state
|
| 76 |
|
| 77 |
async start(config: TelegramRuntimeConfig): Promise<void> {
|
|
@@ -107,6 +166,18 @@ class TelegramService {
|
|
| 107 |
|
| 108 |
if (runId !== this.runId || abortController.signal.aborted) return
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
this.setState({
|
| 111 |
status: 'running',
|
| 112 |
botUsername: response.result?.username,
|
|
@@ -115,7 +186,7 @@ class TelegramService {
|
|
| 115 |
lastError: undefined,
|
| 116 |
})
|
| 117 |
|
| 118 |
-
|
| 119 |
`[telegram] connected as @${response.result?.username ?? 'unknown'}`,
|
| 120 |
)
|
| 121 |
|
|
@@ -155,23 +226,79 @@ class TelegramService {
|
|
| 155 |
}
|
| 156 |
}
|
| 157 |
|
| 158 |
-
async sendMessage(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
if (!this.config) {
|
| 160 |
throw new Error('Telegram service is not running')
|
| 161 |
}
|
| 162 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
for (const chunk of chunkTelegramMessage(text)) {
|
| 164 |
-
|
|
|
|
| 165 |
this.config,
|
| 166 |
'sendMessage',
|
| 167 |
{
|
| 168 |
-
chat_id:
|
| 169 |
text: chunk,
|
|
|
|
| 170 |
},
|
| 171 |
)
|
|
|
|
|
|
|
| 172 |
}
|
|
|
|
| 173 |
}
|
| 174 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
private setState(nextState: TelegramServiceState): void {
|
| 176 |
this.state = nextState
|
| 177 |
for (const listener of this.listeners) {
|
|
@@ -191,6 +318,12 @@ class TelegramService {
|
|
| 191 |
listener(event)
|
| 192 |
}
|
| 193 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
private async pollLoop(
|
| 196 |
runId: number,
|
|
@@ -205,7 +338,7 @@ class TelegramService {
|
|
| 205 |
{
|
| 206 |
offset: this.nextUpdateOffset,
|
| 207 |
timeout: POLL_TIMEOUT_SECONDS,
|
| 208 |
-
allowed_updates: ['message'],
|
| 209 |
},
|
| 210 |
signal,
|
| 211 |
)
|
|
@@ -216,39 +349,103 @@ class TelegramService {
|
|
| 216 |
this.patchState({ lastError: undefined })
|
| 217 |
}
|
| 218 |
|
| 219 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
this.handleUpdate(update, config)
|
| 221 |
}
|
| 222 |
} catch (error) {
|
| 223 |
if (signal.aborted || runId !== this.runId) return
|
| 224 |
|
| 225 |
const message = normalizeTelegramError(error)
|
| 226 |
-
|
| 227 |
-
level: 'error',
|
| 228 |
-
})
|
| 229 |
this.patchState({ lastError: message })
|
| 230 |
await sleep(RETRY_DELAY_MS)
|
| 231 |
}
|
| 232 |
}
|
| 233 |
}
|
| 234 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 235 |
private handleUpdate(
|
| 236 |
update: TelegramUpdate,
|
| 237 |
config: TelegramRuntimeConfig,
|
| 238 |
): void {
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
-
const message = update.message
|
| 242 |
const chatId = message?.chat?.id
|
| 243 |
const userId = message?.from?.id
|
| 244 |
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
if (message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 248 |
|
| 249 |
const normalizedChatId = String(chatId)
|
| 250 |
const normalizedUserId = String(userId)
|
| 251 |
|
|
|
|
|
|
|
| 252 |
if (!config.allowedUserIds.includes(normalizedUserId)) {
|
| 253 |
void this.sendMessage(
|
| 254 |
normalizedChatId,
|
|
@@ -264,6 +461,8 @@ class TelegramService {
|
|
| 264 |
|
| 265 |
const text = message.text?.trim()
|
| 266 |
|
|
|
|
|
|
|
| 267 |
if (!text) {
|
| 268 |
void this.sendMessage(
|
| 269 |
normalizedChatId,
|
|
@@ -280,6 +479,7 @@ class TelegramService {
|
|
| 280 |
return
|
| 281 |
}
|
| 282 |
|
|
|
|
| 283 |
this.emitInbound({
|
| 284 |
kind: 'inbound-message',
|
| 285 |
chatId: normalizedChatId,
|
|
@@ -288,6 +488,9 @@ class TelegramService {
|
|
| 288 |
messageId: message.message_id,
|
| 289 |
updateId: update.update_id,
|
| 290 |
})
|
|
|
|
|
|
|
|
|
|
| 291 |
}
|
| 292 |
|
| 293 |
private async callTelegram<T>(
|
|
@@ -296,34 +499,68 @@ class TelegramService {
|
|
| 296 |
payload: Record<string, unknown>,
|
| 297 |
signal?: AbortSignal,
|
| 298 |
): Promise<T> {
|
| 299 |
-
|
| 300 |
-
`${TELEGRAM_API_BASE}/bot${config.botToken}/${method}`,
|
| 301 |
-
{
|
| 302 |
-
method: 'POST',
|
| 303 |
-
headers: {
|
| 304 |
-
'content-type': 'application/json',
|
| 305 |
-
},
|
| 306 |
-
body: JSON.stringify(payload),
|
| 307 |
-
signal,
|
| 308 |
-
},
|
| 309 |
-
)
|
| 310 |
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
|
|
|
|
|
|
| 314 |
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
)
|
| 324 |
-
}
|
| 325 |
|
| 326 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 327 |
}
|
| 328 |
}
|
| 329 |
|
|
|
|
|
|
|
| 1 |
import { getTelegramRuntimeConfig } from './telegramConfig.js'
|
| 2 |
import type {
|
| 3 |
+
TelegramAnswerCallbackQueryResponse,
|
| 4 |
+
TelegramBotCommand,
|
| 5 |
+
TelegramCallbackEvent,
|
| 6 |
+
TelegramEditMessageResponse,
|
| 7 |
TelegramGetMeResponse,
|
| 8 |
TelegramGetUpdatesResponse,
|
| 9 |
TelegramInboundEvent,
|
| 10 |
+
TelegramInlineKeyboardMarkup,
|
| 11 |
TelegramRuntimeConfig,
|
| 12 |
TelegramSendMessageResponse,
|
| 13 |
+
TelegramSetMyCommandsResponse,
|
| 14 |
TelegramServiceState,
|
| 15 |
TelegramUpdate,
|
| 16 |
} from './telegramTypes.js'
|
| 17 |
|
| 18 |
+
// 日志函数 - 只写入文件
|
| 19 |
+
function logTelegramDebug(message: string, level: 'debug' | 'error' | 'info' = 'debug'): void {
|
| 20 |
+
try {
|
| 21 |
+
const fs = require('node:fs')
|
| 22 |
+
const path = require('node:path')
|
| 23 |
+
const LOG_FILE_PATH = path.join(process.cwd(), 'log.md')
|
| 24 |
+
const timestamp = new Date().toISOString()
|
| 25 |
+
const logEntry = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`
|
| 26 |
+
fs.appendFileSync(LOG_FILE_PATH, logEntry, 'utf-8')
|
| 27 |
+
} catch (error) {
|
| 28 |
+
// 忽略文件写入错误
|
| 29 |
+
}
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
type Listener = () => void
|
| 33 |
type InboundListener = (event: TelegramInboundEvent) => void
|
| 34 |
+
type CallbackListener = (event: TelegramCallbackEvent) => void
|
| 35 |
|
| 36 |
const TELEGRAM_API_BASE = 'https://api.telegram.org'
|
| 37 |
const MAX_TELEGRAM_MESSAGE_LENGTH = 4000
|
| 38 |
const POLL_TIMEOUT_SECONDS = 25
|
| 39 |
const RETRY_DELAY_MS = 3000
|
| 40 |
+
const MAX_TELEGRAM_MENU_COMMANDS = 100
|
| 41 |
+
const TELEGRAM_COMMAND_NAME_RE = /^[a-z0-9_]{1,32}$/
|
| 42 |
|
| 43 |
function sleep(ms: number): Promise<void> {
|
| 44 |
return new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
| 71 |
)
|
| 72 |
}
|
| 73 |
|
| 74 |
+
function normalizeCommandDescription(description: string): string {
|
| 75 |
+
return description.replace(/\s+/g, ' ').trim().slice(0, 256)
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
async function getTelegramMenuCommands(): Promise<TelegramBotCommand[]> {
|
| 79 |
+
const { getCommands, getCommandName } = await import('../../commands.js')
|
| 80 |
+
const commands = await getCommands(process.cwd())
|
| 81 |
+
const results: TelegramBotCommand[] = []
|
| 82 |
+
const seen = new Set<string>()
|
| 83 |
+
|
| 84 |
+
for (const command of commands) {
|
| 85 |
+
const candidates = [getCommandName(command), ...(command.aliases ?? [])]
|
| 86 |
+
const name = candidates.find(candidate => TELEGRAM_COMMAND_NAME_RE.test(candidate))
|
| 87 |
+
if (!name || seen.has(name)) continue
|
| 88 |
+
|
| 89 |
+
results.push({
|
| 90 |
+
command: name,
|
| 91 |
+
description: normalizeCommandDescription(command.description),
|
| 92 |
+
})
|
| 93 |
+
seen.add(name)
|
| 94 |
+
|
| 95 |
+
if (results.length >= MAX_TELEGRAM_MENU_COMMANDS) {
|
| 96 |
+
break
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
return results
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
class TelegramService {
|
| 104 |
private listeners = new Set<Listener>()
|
| 105 |
private inboundListeners = new Set<InboundListener>()
|
| 106 |
+
private callbackListeners = new Set<CallbackListener>()
|
| 107 |
private state: TelegramServiceState = { status: 'stopped' }
|
| 108 |
private config?: TelegramRuntimeConfig
|
| 109 |
private abortController: AbortController | null = null
|
|
|
|
| 124 |
}
|
| 125 |
}
|
| 126 |
|
| 127 |
+
subscribeToCallbacks = (listener: CallbackListener): (() => void) => {
|
| 128 |
+
this.callbackListeners.add(listener)
|
| 129 |
+
return () => {
|
| 130 |
+
this.callbackListeners.delete(listener)
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
getStateSnapshot = (): TelegramServiceState => this.state
|
| 135 |
|
| 136 |
async start(config: TelegramRuntimeConfig): Promise<void> {
|
|
|
|
| 166 |
|
| 167 |
if (runId !== this.runId || abortController.signal.aborted) return
|
| 168 |
|
| 169 |
+
try {
|
| 170 |
+
await this.refreshTelegramMenu(config, abortController.signal)
|
| 171 |
+
} catch (error) {
|
| 172 |
+
if (!abortController.signal.aborted && runId === this.runId) {
|
| 173 |
+
const message = normalizeTelegramError(error)
|
| 174 |
+
logTelegramDebug(`[telegram] failed to refresh menu: ${message}`, 'error')
|
| 175 |
+
this.patchState({ lastError: message })
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
if (runId !== this.runId || abortController.signal.aborted) return
|
| 180 |
+
|
| 181 |
this.setState({
|
| 182 |
status: 'running',
|
| 183 |
botUsername: response.result?.username,
|
|
|
|
| 186 |
lastError: undefined,
|
| 187 |
})
|
| 188 |
|
| 189 |
+
logTelegramDebug(
|
| 190 |
`[telegram] connected as @${response.result?.username ?? 'unknown'}`,
|
| 191 |
)
|
| 192 |
|
|
|
|
| 226 |
}
|
| 227 |
}
|
| 228 |
|
| 229 |
+
async sendMessage(
|
| 230 |
+
chatId: string,
|
| 231 |
+
text: string,
|
| 232 |
+
replyMarkup?: TelegramInlineKeyboardMarkup,
|
| 233 |
+
): Promise<number | undefined> {
|
| 234 |
if (!this.config) {
|
| 235 |
throw new Error('Telegram service is not running')
|
| 236 |
}
|
| 237 |
|
| 238 |
+
const numericChatId = Number(chatId)
|
| 239 |
+
logTelegramDebug(`[telegram] sendMessage called: chatId=${chatId} (type: ${typeof chatId}), numericChatId=${numericChatId} (type: ${typeof numericChatId}), text length=${text.length}`)
|
| 240 |
+
logTelegramDebug(`[telegram] allowedUserIds: ${JSON.stringify(this.config.allowedUserIds)}`)
|
| 241 |
+
|
| 242 |
+
let lastMessageId: number | undefined
|
| 243 |
+
|
| 244 |
for (const chunk of chunkTelegramMessage(text)) {
|
| 245 |
+
logTelegramDebug(`[telegram] sending chunk to chat_id: ${numericChatId}`)
|
| 246 |
+
const response = await this.callTelegram<TelegramSendMessageResponse>(
|
| 247 |
this.config,
|
| 248 |
'sendMessage',
|
| 249 |
{
|
| 250 |
+
chat_id: numericChatId,
|
| 251 |
text: chunk,
|
| 252 |
+
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
| 253 |
},
|
| 254 |
)
|
| 255 |
+
lastMessageId = response.result?.message_id
|
| 256 |
+
logTelegramDebug(`[telegram] sendMessage succeeded: messageId=${lastMessageId}`)
|
| 257 |
}
|
| 258 |
+
return lastMessageId
|
| 259 |
}
|
| 260 |
|
| 261 |
+
async editMessage(
|
| 262 |
+
chatId: string,
|
| 263 |
+
messageId: number,
|
| 264 |
+
text: string,
|
| 265 |
+
replyMarkup?: TelegramInlineKeyboardMarkup,
|
| 266 |
+
): Promise<void> {
|
| 267 |
+
if (!this.config) {
|
| 268 |
+
throw new Error('Telegram service is not running')
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
await this.callTelegram<TelegramEditMessageResponse>(
|
| 272 |
+
this.config,
|
| 273 |
+
'editMessageText',
|
| 274 |
+
{
|
| 275 |
+
chat_id: Number(chatId),
|
| 276 |
+
message_id: messageId,
|
| 277 |
+
text,
|
| 278 |
+
...(replyMarkup ? { reply_markup: replyMarkup } : {}),
|
| 279 |
+
},
|
| 280 |
+
)
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
async answerCallbackQuery(
|
| 284 |
+
callbackQueryId: string,
|
| 285 |
+
text?: string,
|
| 286 |
+
): Promise<void> {
|
| 287 |
+
if (!this.config) {
|
| 288 |
+
throw new Error('Telegram service is not running')
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
await this.callTelegram<TelegramAnswerCallbackQueryResponse>(
|
| 292 |
+
this.config,
|
| 293 |
+
'answerCallbackQuery',
|
| 294 |
+
{
|
| 295 |
+
callback_query_id: callbackQueryId,
|
| 296 |
+
...(text ? { text } : {}),
|
| 297 |
+
},
|
| 298 |
+
)
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
|
| 302 |
private setState(nextState: TelegramServiceState): void {
|
| 303 |
this.state = nextState
|
| 304 |
for (const listener of this.listeners) {
|
|
|
|
| 318 |
listener(event)
|
| 319 |
}
|
| 320 |
}
|
| 321 |
+
|
| 322 |
+
private emitCallback(event: TelegramCallbackEvent): void {
|
| 323 |
+
for (const listener of this.callbackListeners) {
|
| 324 |
+
listener(event)
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
|
| 328 |
private async pollLoop(
|
| 329 |
runId: number,
|
|
|
|
| 338 |
{
|
| 339 |
offset: this.nextUpdateOffset,
|
| 340 |
timeout: POLL_TIMEOUT_SECONDS,
|
| 341 |
+
allowed_updates: ['message', 'callback_query'],
|
| 342 |
},
|
| 343 |
signal,
|
| 344 |
)
|
|
|
|
| 349 |
this.patchState({ lastError: undefined })
|
| 350 |
}
|
| 351 |
|
| 352 |
+
const updates = response.result ?? []
|
| 353 |
+
logTelegramDebug(`[telegram] received ${updates.length} updates from Telegram`)
|
| 354 |
+
|
| 355 |
+
for (const update of updates) {
|
| 356 |
+
logTelegramDebug(`[telegram] processing update: update_id=${update.update_id}`)
|
| 357 |
this.handleUpdate(update, config)
|
| 358 |
}
|
| 359 |
} catch (error) {
|
| 360 |
if (signal.aborted || runId !== this.runId) return
|
| 361 |
|
| 362 |
const message = normalizeTelegramError(error)
|
| 363 |
+
logTelegramDebug(`[telegram] polling failed: ${message}`, 'error')
|
|
|
|
|
|
|
| 364 |
this.patchState({ lastError: message })
|
| 365 |
await sleep(RETRY_DELAY_MS)
|
| 366 |
}
|
| 367 |
}
|
| 368 |
}
|
| 369 |
|
| 370 |
+
private async refreshTelegramMenu(
|
| 371 |
+
config: TelegramRuntimeConfig,
|
| 372 |
+
signal: AbortSignal,
|
| 373 |
+
): Promise<void> {
|
| 374 |
+
const commands = await getTelegramMenuCommands()
|
| 375 |
+
|
| 376 |
+
await this.callTelegram<TelegramSetMyCommandsResponse>(
|
| 377 |
+
config,
|
| 378 |
+
'setMyCommands',
|
| 379 |
+
{
|
| 380 |
+
commands,
|
| 381 |
+
},
|
| 382 |
+
signal,
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
logTelegramDebug(`[telegram] refreshed menu commands: ${commands.length}`)
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
private handleUpdate(
|
| 389 |
update: TelegramUpdate,
|
| 390 |
config: TelegramRuntimeConfig,
|
| 391 |
): void {
|
| 392 |
+
try {
|
| 393 |
+
logTelegramDebug(`[telegram] handleUpdate called, update_id: ${update.update_id}`)
|
| 394 |
+
this.nextUpdateOffset = update.update_id + 1
|
| 395 |
+
|
| 396 |
+
const message = update.message
|
| 397 |
+
const callbackQuery = update.callback_query
|
| 398 |
+
|
| 399 |
+
logTelegramDebug(`[telegram] update structure: has_message=${!!message}, has_callback=${!!callbackQuery}`)
|
| 400 |
+
|
| 401 |
+
if (callbackQuery?.data) {
|
| 402 |
+
const chatId = callbackQuery.message?.chat?.id
|
| 403 |
+
const userId = callbackQuery.from?.id
|
| 404 |
+
const messageId = callbackQuery.message?.message_id
|
| 405 |
+
if (
|
| 406 |
+
chatId !== undefined &&
|
| 407 |
+
userId !== undefined &&
|
| 408 |
+
messageId !== undefined &&
|
| 409 |
+
!callbackQuery.from?.is_bot
|
| 410 |
+
) {
|
| 411 |
+
if (!config.allowedUserIds.includes(String(userId))) {
|
| 412 |
+
return
|
| 413 |
+
}
|
| 414 |
+
this.emitCallback({
|
| 415 |
+
kind: 'callback-query',
|
| 416 |
+
callbackQueryId: callbackQuery.id,
|
| 417 |
+
chatId: String(chatId),
|
| 418 |
+
userId: String(userId),
|
| 419 |
+
messageId,
|
| 420 |
+
data: callbackQuery.data,
|
| 421 |
+
})
|
| 422 |
+
}
|
| 423 |
+
return
|
| 424 |
+
}
|
| 425 |
|
|
|
|
| 426 |
const chatId = message?.chat?.id
|
| 427 |
const userId = message?.from?.id
|
| 428 |
|
| 429 |
+
logTelegramDebug(`[telegram] message details: chatId=${chatId}, userId=${userId}, is_bot=${message?.from?.is_bot}, chat_type=${message?.chat?.type}`)
|
| 430 |
+
|
| 431 |
+
if (!message || chatId === undefined || userId === undefined) {
|
| 432 |
+
logTelegramDebug(`[telegram] message rejected: missing required fields`)
|
| 433 |
+
return
|
| 434 |
+
}
|
| 435 |
+
if (message.from?.is_bot) {
|
| 436 |
+
logTelegramDebug(`[telegram] message rejected: from bot`)
|
| 437 |
+
return
|
| 438 |
+
}
|
| 439 |
+
if (message.chat?.type !== 'private') {
|
| 440 |
+
logTelegramDebug(`[telegram] message rejected: not private chat`)
|
| 441 |
+
return
|
| 442 |
+
}
|
| 443 |
|
| 444 |
const normalizedChatId = String(chatId)
|
| 445 |
const normalizedUserId = String(userId)
|
| 446 |
|
| 447 |
+
logTelegramDebug(`[telegram] user validation: normalizedUserId=${normalizedUserId}, allowedUserIds=${JSON.stringify(config.allowedUserIds)}`)
|
| 448 |
+
|
| 449 |
if (!config.allowedUserIds.includes(normalizedUserId)) {
|
| 450 |
void this.sendMessage(
|
| 451 |
normalizedChatId,
|
|
|
|
| 461 |
|
| 462 |
const text = message.text?.trim()
|
| 463 |
|
| 464 |
+
logTelegramDebug(`[telegram] inbound message from chatId: ${normalizedChatId}, userId: ${normalizedUserId}, text: ${text?.slice(0, 50)}`)
|
| 465 |
+
|
| 466 |
if (!text) {
|
| 467 |
void this.sendMessage(
|
| 468 |
normalizedChatId,
|
|
|
|
| 479 |
return
|
| 480 |
}
|
| 481 |
|
| 482 |
+
logTelegramDebug(`[telegram] emitting inbound event with chatId: ${normalizedChatId}`)
|
| 483 |
this.emitInbound({
|
| 484 |
kind: 'inbound-message',
|
| 485 |
chatId: normalizedChatId,
|
|
|
|
| 488 |
messageId: message.message_id,
|
| 489 |
updateId: update.update_id,
|
| 490 |
})
|
| 491 |
+
} catch (error) {
|
| 492 |
+
logTelegramDebug(`[telegram] handleUpdate error: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
| 493 |
+
}
|
| 494 |
}
|
| 495 |
|
| 496 |
private async callTelegram<T>(
|
|
|
|
| 499 |
payload: Record<string, unknown>,
|
| 500 |
signal?: AbortSignal,
|
| 501 |
): Promise<T> {
|
| 502 |
+
logTelegramDebug(`[telegram] calling API: ${method}`)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
|
| 504 |
+
// 创建超时信号(30秒超时)
|
| 505 |
+
const timeoutController = new AbortController()
|
| 506 |
+
const timeoutId = setTimeout(() => {
|
| 507 |
+
timeoutController.abort()
|
| 508 |
+
}, 30000)
|
| 509 |
|
| 510 |
+
// 合并外部信号和超时信号
|
| 511 |
+
const combinedSignal = signal
|
| 512 |
+
? AbortSignal.any([signal, timeoutController.signal])
|
| 513 |
+
: timeoutController.signal
|
| 514 |
|
| 515 |
+
try {
|
| 516 |
+
const response = await fetch(
|
| 517 |
+
`${TELEGRAM_API_BASE}/bot${config.botToken}/${method}`,
|
| 518 |
+
{
|
| 519 |
+
method: 'POST',
|
| 520 |
+
headers: {
|
| 521 |
+
'content-type': 'application/json',
|
| 522 |
+
},
|
| 523 |
+
body: JSON.stringify(payload),
|
| 524 |
+
signal: combinedSignal,
|
| 525 |
+
},
|
| 526 |
)
|
|
|
|
| 527 |
|
| 528 |
+
clearTimeout(timeoutId)
|
| 529 |
+
|
| 530 |
+
if (!response.ok) {
|
| 531 |
+
const errorText = await response.text().catch(() => 'unknown error')
|
| 532 |
+
logTelegramDebug(`[telegram] API error: ${method} failed with HTTP ${response.status}: ${errorText}`, 'error')
|
| 533 |
+
throw new Error(`Telegram API ${method} failed with HTTP ${response.status}: ${errorText}`)
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
const json = await response.json() as {
|
| 537 |
+
ok?: boolean
|
| 538 |
+
description?: string
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
if (!json.ok) {
|
| 542 |
+
logTelegramDebug(`[telegram] API error: ${method} failed: ${json.description ?? 'unknown error'}`, 'error')
|
| 543 |
+
throw new Error(
|
| 544 |
+
`Telegram API ${method} failed: ${json.description ?? 'unknown error'}`,
|
| 545 |
+
)
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
return json as T
|
| 549 |
+
} catch (error) {
|
| 550 |
+
clearTimeout(timeoutId)
|
| 551 |
+
|
| 552 |
+
if (error instanceof Error) {
|
| 553 |
+
if (error.name === 'AbortError') {
|
| 554 |
+
logTelegramDebug(`[telegram] API error: ${method} timed out or was aborted`, 'error')
|
| 555 |
+
throw new Error(`Telegram API ${method} timed out`)
|
| 556 |
+
}
|
| 557 |
+
logTelegramDebug(`[telegram] API error: ${method} failed: ${error.message}`, 'error')
|
| 558 |
+
throw new Error(`Telegram API ${method} failed: ${error.message}`)
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
logTelegramDebug(`[telegram] API error: ${method} failed with unknown error`, 'error')
|
| 562 |
+
throw new Error(`Telegram API ${method} failed with unknown error`)
|
| 563 |
+
}
|
| 564 |
}
|
| 565 |
}
|
| 566 |
|
src/services/telegram/interactiveCommands.ts
ADDED
|
@@ -0,0 +1,653 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { AppStateStore } from '../../state/AppState.js'
|
| 2 |
+
import { saveGlobalConfig } from '../../utils/config.js'
|
| 3 |
+
import { logForDebugging } from '../../utils/debug.js'
|
| 4 |
+
import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'
|
| 5 |
+
import {
|
| 6 |
+
clearFastModeCooldown,
|
| 7 |
+
isFastModeAvailable,
|
| 8 |
+
isFastModeEnabled,
|
| 9 |
+
isFastModeSupportedByModel,
|
| 10 |
+
} from '../../utils/fastMode.js'
|
| 11 |
+
import {
|
| 12 |
+
getDefaultMainLoopModelSetting,
|
| 13 |
+
isOpus1mMergeEnabled,
|
| 14 |
+
renderDefaultModelSetting,
|
| 15 |
+
} from '../../utils/model/model.js'
|
| 16 |
+
import {
|
| 17 |
+
getModelOptions,
|
| 18 |
+
type ModelOption,
|
| 19 |
+
} from '../../utils/model/modelOptions.js'
|
| 20 |
+
import { fetchCopilotModels } from '../api/copilotClient.js'
|
| 21 |
+
import {
|
| 22 |
+
fetchAnthropicCompatibleModelIds,
|
| 23 |
+
fetchOpenAICompatibleModelIds,
|
| 24 |
+
} from '../api/customOpenAIClient.js'
|
| 25 |
+
import { telegramService } from './TelegramService.js'
|
| 26 |
+
import type {
|
| 27 |
+
TelegramCallbackEvent,
|
| 28 |
+
TelegramInboundEvent,
|
| 29 |
+
TelegramInlineKeyboardButton,
|
| 30 |
+
TelegramInlineKeyboardMarkup,
|
| 31 |
+
} from './telegramTypes.js'
|
| 32 |
+
|
| 33 |
+
const COPILOT_CLIENT_ID = 'Ov23li8tweQw6odWQebz'
|
| 34 |
+
const COPILOT_DEVICE_CODE_URL = 'https://github.com/login/device/code'
|
| 35 |
+
const COPILOT_ACCESS_TOKEN_URL = 'https://github.com/login/oauth/access_token'
|
| 36 |
+
const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
|
| 37 |
+
|
| 38 |
+
const MODEL_PREFIX = 'tg:model'
|
| 39 |
+
const CONNECT_PREFIX = 'tg:connect'
|
| 40 |
+
|
| 41 |
+
type ConnectPendingState =
|
| 42 |
+
| { kind: 'openrouter-api-key' }
|
| 43 |
+
| { kind: 'custom-openai-base' }
|
| 44 |
+
| { kind: 'custom-openai-key'; baseUrl: string }
|
| 45 |
+
| { kind: 'custom-anthropic-base' }
|
| 46 |
+
| { kind: 'custom-anthropic-key'; baseUrl: string }
|
| 47 |
+
|
| 48 |
+
type ConnectModelSelectionState = {
|
| 49 |
+
providerId: 'custom-openai' | 'custom-anthropic'
|
| 50 |
+
baseUrl: string
|
| 51 |
+
apiKey?: string
|
| 52 |
+
models: string[]
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
const pendingConnectInputs = new Map<string, ConnectPendingState>()
|
| 56 |
+
const pendingModelMenus = new Map<string, ModelOption[]>()
|
| 57 |
+
const pendingConnectModelMenus = new Map<string, ConnectModelSelectionState>()
|
| 58 |
+
|
| 59 |
+
function chunk<T>(items: T[], size: number): T[][] {
|
| 60 |
+
const rows: T[][] = []
|
| 61 |
+
for (let i = 0; i < items.length; i += size) {
|
| 62 |
+
rows.push(items.slice(i, i + size))
|
| 63 |
+
}
|
| 64 |
+
return rows
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
function createKeyboard(
|
| 68 |
+
rows: TelegramInlineKeyboardButton[][],
|
| 69 |
+
): TelegramInlineKeyboardMarkup {
|
| 70 |
+
return { inline_keyboard: rows }
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
function buildModelKeyboard(options: ModelOption[]): TelegramInlineKeyboardMarkup {
|
| 74 |
+
return createKeyboard(
|
| 75 |
+
chunk(
|
| 76 |
+
options.map((option, index) => ({
|
| 77 |
+
text: option.label,
|
| 78 |
+
callback_data: `${MODEL_PREFIX}:select:${index}`,
|
| 79 |
+
})),
|
| 80 |
+
2,
|
| 81 |
+
),
|
| 82 |
+
)
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
function buildConnectKeyboard(): TelegramInlineKeyboardMarkup {
|
| 86 |
+
return createKeyboard([
|
| 87 |
+
[
|
| 88 |
+
{ text: 'GitHub Copilot', callback_data: `${CONNECT_PREFIX}:provider:github-copilot` },
|
| 89 |
+
{ text: 'OpenRouter', callback_data: `${CONNECT_PREFIX}:provider:openrouter` },
|
| 90 |
+
],
|
| 91 |
+
[
|
| 92 |
+
{ text: 'Custom OpenAI', callback_data: `${CONNECT_PREFIX}:provider:custom-openai` },
|
| 93 |
+
{ text: 'Custom Anthropic', callback_data: `${CONNECT_PREFIX}:provider:custom-anthropic` },
|
| 94 |
+
],
|
| 95 |
+
])
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
function buildSkipKeyKeyboard(providerId: 'custom-openai' | 'custom-anthropic') {
|
| 99 |
+
return createKeyboard([
|
| 100 |
+
[
|
| 101 |
+
{
|
| 102 |
+
text: '跳过 API Key',
|
| 103 |
+
callback_data: `${CONNECT_PREFIX}:skipkey:${providerId}`,
|
| 104 |
+
},
|
| 105 |
+
],
|
| 106 |
+
])
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function buildModelSelectionKeyboard(
|
| 110 |
+
providerId: 'custom-openai' | 'custom-anthropic',
|
| 111 |
+
models: string[],
|
| 112 |
+
): TelegramInlineKeyboardMarkup {
|
| 113 |
+
return createKeyboard(
|
| 114 |
+
chunk(
|
| 115 |
+
models.map((model, index) => ({
|
| 116 |
+
text: model,
|
| 117 |
+
callback_data: `${CONNECT_PREFIX}:model:${providerId}:${index}`,
|
| 118 |
+
})),
|
| 119 |
+
1,
|
| 120 |
+
),
|
| 121 |
+
)
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function renderModelSelectionText(store: AppStateStore, options: ModelOption[]): string {
|
| 125 |
+
const state = store.getState()
|
| 126 |
+
const current = state.mainLoopModel
|
| 127 |
+
const currentLabel = renderDefaultModelSetting(
|
| 128 |
+
current ?? getDefaultMainLoopModelSetting(),
|
| 129 |
+
)
|
| 130 |
+
const lines = [
|
| 131 |
+
`当前模型: ${currentLabel}${current === null ? ' (default)' : ''}`,
|
| 132 |
+
'',
|
| 133 |
+
'请选择要切换的模型:',
|
| 134 |
+
]
|
| 135 |
+
|
| 136 |
+
options.forEach((option, index) => {
|
| 137 |
+
lines.push(`${index + 1}. ${option.label} - ${option.description}`)
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
return lines.join('\n')
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
function applyModelSelection(
|
| 144 |
+
store: AppStateStore,
|
| 145 |
+
model: ModelOption['value'],
|
| 146 |
+
): string {
|
| 147 |
+
const state = store.getState()
|
| 148 |
+
let message = `已切换到模型 ${renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting())}${model === null ? ' (default)' : ''}`
|
| 149 |
+
let wasFastModeToggledOn: boolean | undefined
|
| 150 |
+
|
| 151 |
+
store.setState(prev => ({
|
| 152 |
+
...prev,
|
| 153 |
+
mainLoopModel: model,
|
| 154 |
+
mainLoopModelForSession: null,
|
| 155 |
+
}))
|
| 156 |
+
|
| 157 |
+
if (isFastModeEnabled()) {
|
| 158 |
+
clearFastModeCooldown()
|
| 159 |
+
if (!isFastModeSupportedByModel(model) && state.fastMode) {
|
| 160 |
+
store.setState(prev => ({
|
| 161 |
+
...prev,
|
| 162 |
+
fastMode: false,
|
| 163 |
+
}))
|
| 164 |
+
wasFastModeToggledOn = false
|
| 165 |
+
} else if (
|
| 166 |
+
isFastModeSupportedByModel(model) &&
|
| 167 |
+
isFastModeAvailable() &&
|
| 168 |
+
state.fastMode
|
| 169 |
+
) {
|
| 170 |
+
message += ' · Fast mode ON'
|
| 171 |
+
wasFastModeToggledOn = true
|
| 172 |
+
}
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) {
|
| 176 |
+
message += ' · Billed as extra usage'
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
if (wasFastModeToggledOn === false) {
|
| 180 |
+
message += ' · Fast mode OFF'
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
return message
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
async function beginCopilotOAuth(chatId: string): Promise<void> {
|
| 187 |
+
const response = await fetch(COPILOT_DEVICE_CODE_URL, {
|
| 188 |
+
method: 'POST',
|
| 189 |
+
headers: {
|
| 190 |
+
Accept: 'application/json',
|
| 191 |
+
'Content-Type': 'application/json',
|
| 192 |
+
},
|
| 193 |
+
body: JSON.stringify({
|
| 194 |
+
client_id: COPILOT_CLIENT_ID,
|
| 195 |
+
scope: 'read:user',
|
| 196 |
+
}),
|
| 197 |
+
})
|
| 198 |
+
|
| 199 |
+
if (!response.ok) {
|
| 200 |
+
throw new Error('无法发起 GitHub Copilot 设备授权')
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
const data = (await response.json()) as {
|
| 204 |
+
verification_uri: string
|
| 205 |
+
user_code: string
|
| 206 |
+
device_code: string
|
| 207 |
+
interval: number
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
await telegramService.sendMessage(
|
| 211 |
+
chatId,
|
| 212 |
+
[
|
| 213 |
+
'GitHub Copilot 连接已开始。',
|
| 214 |
+
`1. 打开: ${data.verification_uri}`,
|
| 215 |
+
`2. 输入代码: ${data.user_code}`,
|
| 216 |
+
'授权完成后,我会自动继续。',
|
| 217 |
+
].join('\n'),
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
void pollCopilotOAuth(chatId, data)
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
async function pollCopilotOAuth(
|
| 224 |
+
chatId: string,
|
| 225 |
+
data: {
|
| 226 |
+
device_code: string
|
| 227 |
+
interval: number
|
| 228 |
+
},
|
| 229 |
+
): Promise<void> {
|
| 230 |
+
let currentInterval = data.interval
|
| 231 |
+
|
| 232 |
+
while (true) {
|
| 233 |
+
await new Promise(resolve =>
|
| 234 |
+
setTimeout(resolve, currentInterval * 1000 + OAUTH_POLLING_SAFETY_MARGIN_MS),
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
const response = await fetch(COPILOT_ACCESS_TOKEN_URL, {
|
| 238 |
+
method: 'POST',
|
| 239 |
+
headers: {
|
| 240 |
+
Accept: 'application/json',
|
| 241 |
+
'Content-Type': 'application/json',
|
| 242 |
+
},
|
| 243 |
+
body: JSON.stringify({
|
| 244 |
+
client_id: COPILOT_CLIENT_ID,
|
| 245 |
+
device_code: data.device_code,
|
| 246 |
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
| 247 |
+
}),
|
| 248 |
+
})
|
| 249 |
+
|
| 250 |
+
if (!response.ok) {
|
| 251 |
+
throw new Error('获取 GitHub Copilot access token 失败')
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
const result = (await response.json()) as {
|
| 255 |
+
access_token?: string
|
| 256 |
+
error?: string
|
| 257 |
+
interval?: number
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
if (result.access_token) {
|
| 261 |
+
saveGlobalConfig(current => ({
|
| 262 |
+
...current,
|
| 263 |
+
connectedProviders: {
|
| 264 |
+
...(current.connectedProviders || {}),
|
| 265 |
+
'github-copilot': {
|
| 266 |
+
oauthToken: result.access_token,
|
| 267 |
+
connectedAt: new Date().toISOString(),
|
| 268 |
+
},
|
| 269 |
+
},
|
| 270 |
+
activeProvider: current.activeProvider || 'github-copilot',
|
| 271 |
+
}))
|
| 272 |
+
|
| 273 |
+
fetchCopilotModels()
|
| 274 |
+
.then(models => {
|
| 275 |
+
saveGlobalConfig(current => ({
|
| 276 |
+
...current,
|
| 277 |
+
copilotModelsCache: { models, fetchedAt: Date.now() },
|
| 278 |
+
}))
|
| 279 |
+
})
|
| 280 |
+
.catch(() => {})
|
| 281 |
+
|
| 282 |
+
await telegramService.sendMessage(
|
| 283 |
+
chatId,
|
| 284 |
+
'GitHub Copilot 已连接成功。之后可用 /model 选择 Copilot 模型。',
|
| 285 |
+
)
|
| 286 |
+
return
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
if (result.error === 'authorization_pending') {
|
| 290 |
+
continue
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
if (result.error === 'slow_down') {
|
| 294 |
+
currentInterval = result.interval ?? currentInterval + 5
|
| 295 |
+
continue
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
if (result.error === 'expired_token') {
|
| 299 |
+
throw new Error('GitHub Copilot 授权已过期,请重新执行 /connect')
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
if (result.error === 'access_denied') {
|
| 303 |
+
throw new Error('GitHub Copilot 授权被拒绝')
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
if (result.error) {
|
| 307 |
+
throw new Error(`GitHub Copilot OAuth 错误: ${result.error}`)
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
}
|
| 311 |
+
|
| 312 |
+
async function handleConnectTextInput(
|
| 313 |
+
chatId: string,
|
| 314 |
+
text: string,
|
| 315 |
+
): Promise<boolean> {
|
| 316 |
+
const pending = pendingConnectInputs.get(chatId)
|
| 317 |
+
if (!pending) return false
|
| 318 |
+
|
| 319 |
+
pendingConnectInputs.delete(chatId)
|
| 320 |
+
|
| 321 |
+
switch (pending.kind) {
|
| 322 |
+
case 'openrouter-api-key': {
|
| 323 |
+
if (!text.trim()) {
|
| 324 |
+
await telegramService.sendMessage(chatId, 'API Key 不能为空,请重新执行 /connect。')
|
| 325 |
+
return true
|
| 326 |
+
}
|
| 327 |
+
saveGlobalConfig(current => ({
|
| 328 |
+
...current,
|
| 329 |
+
connectedProviders: {
|
| 330 |
+
...(current.connectedProviders || {}),
|
| 331 |
+
openrouter: {
|
| 332 |
+
apiKey: text.trim(),
|
| 333 |
+
connectedAt: new Date().toISOString(),
|
| 334 |
+
},
|
| 335 |
+
},
|
| 336 |
+
activeProvider: current.activeProvider || 'openrouter',
|
| 337 |
+
}))
|
| 338 |
+
await telegramService.sendMessage(chatId, 'OpenRouter 已连接成功。')
|
| 339 |
+
return true
|
| 340 |
+
}
|
| 341 |
+
case 'custom-openai-base': {
|
| 342 |
+
const baseUrl = text.trim()
|
| 343 |
+
if (!baseUrl) {
|
| 344 |
+
await telegramService.sendMessage(chatId, 'Base URL 不能为空,请重新执行 /connect。')
|
| 345 |
+
return true
|
| 346 |
+
}
|
| 347 |
+
pendingConnectInputs.set(chatId, {
|
| 348 |
+
kind: 'custom-openai-key',
|
| 349 |
+
baseUrl,
|
| 350 |
+
})
|
| 351 |
+
await telegramService.sendMessage(
|
| 352 |
+
chatId,
|
| 353 |
+
'请输入 Custom OpenAI 的 API Key,或者点击下方按钮跳过。',
|
| 354 |
+
buildSkipKeyKeyboard('custom-openai'),
|
| 355 |
+
)
|
| 356 |
+
return true
|
| 357 |
+
}
|
| 358 |
+
case 'custom-anthropic-base': {
|
| 359 |
+
const baseUrl = text.trim()
|
| 360 |
+
if (!baseUrl) {
|
| 361 |
+
await telegramService.sendMessage(chatId, 'Base URL 不能为空,请重新执行 /connect。')
|
| 362 |
+
return true
|
| 363 |
+
}
|
| 364 |
+
pendingConnectInputs.set(chatId, {
|
| 365 |
+
kind: 'custom-anthropic-key',
|
| 366 |
+
baseUrl,
|
| 367 |
+
})
|
| 368 |
+
await telegramService.sendMessage(
|
| 369 |
+
chatId,
|
| 370 |
+
'请输入 Custom Anthropic 的 API Key,或者点击下方按钮跳过。',
|
| 371 |
+
buildSkipKeyKeyboard('custom-anthropic'),
|
| 372 |
+
)
|
| 373 |
+
return true
|
| 374 |
+
}
|
| 375 |
+
case 'custom-openai-key': {
|
| 376 |
+
await startCustomProviderModelFetch(
|
| 377 |
+
chatId,
|
| 378 |
+
'custom-openai',
|
| 379 |
+
pending.baseUrl,
|
| 380 |
+
text.trim() || undefined,
|
| 381 |
+
)
|
| 382 |
+
return true
|
| 383 |
+
}
|
| 384 |
+
case 'custom-anthropic-key': {
|
| 385 |
+
await startCustomProviderModelFetch(
|
| 386 |
+
chatId,
|
| 387 |
+
'custom-anthropic',
|
| 388 |
+
pending.baseUrl,
|
| 389 |
+
text.trim() || undefined,
|
| 390 |
+
)
|
| 391 |
+
return true
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
async function startCustomProviderModelFetch(
|
| 397 |
+
chatId: string,
|
| 398 |
+
providerId: 'custom-openai' | 'custom-anthropic',
|
| 399 |
+
baseUrl: string,
|
| 400 |
+
apiKey?: string,
|
| 401 |
+
): Promise<void> {
|
| 402 |
+
const models =
|
| 403 |
+
providerId === 'custom-openai'
|
| 404 |
+
? await fetchOpenAICompatibleModelIds(baseUrl, apiKey)
|
| 405 |
+
: await fetchAnthropicCompatibleModelIds(baseUrl, apiKey)
|
| 406 |
+
|
| 407 |
+
if (models.length === 0) {
|
| 408 |
+
await telegramService.sendMessage(
|
| 409 |
+
chatId,
|
| 410 |
+
'没有从 /v1/models 获取到可用模型,请检查 Base URL 或 API Key。',
|
| 411 |
+
)
|
| 412 |
+
return
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
pendingConnectModelMenus.set(chatId, {
|
| 416 |
+
providerId,
|
| 417 |
+
baseUrl,
|
| 418 |
+
apiKey,
|
| 419 |
+
models,
|
| 420 |
+
})
|
| 421 |
+
|
| 422 |
+
await telegramService.sendMessage(
|
| 423 |
+
chatId,
|
| 424 |
+
`请选择 ${providerId === 'custom-openai' ? 'Custom OpenAI' : 'Custom Anthropic'} 的默认模型:`,
|
| 425 |
+
buildModelSelectionKeyboard(providerId, models),
|
| 426 |
+
)
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
export async function maybeHandleTelegramInteractiveInput(
|
| 430 |
+
event: TelegramInboundEvent,
|
| 431 |
+
store: AppStateStore,
|
| 432 |
+
): Promise<boolean> {
|
| 433 |
+
const text = event.text.trim()
|
| 434 |
+
|
| 435 |
+
if (await handleConnectTextInput(event.chatId, text)) {
|
| 436 |
+
return true
|
| 437 |
+
}
|
| 438 |
+
|
| 439 |
+
if (text === '/model') {
|
| 440 |
+
const options = getModelOptions(store.getState().fastMode).slice(0, 20)
|
| 441 |
+
pendingModelMenus.set(event.chatId, options)
|
| 442 |
+
await telegramService.sendMessage(
|
| 443 |
+
event.chatId,
|
| 444 |
+
renderModelSelectionText(store, options),
|
| 445 |
+
buildModelKeyboard(options),
|
| 446 |
+
)
|
| 447 |
+
return true
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
if (text === '/connect') {
|
| 451 |
+
await telegramService.sendMessage(
|
| 452 |
+
event.chatId,
|
| 453 |
+
'请选择要连接的 provider:',
|
| 454 |
+
buildConnectKeyboard(),
|
| 455 |
+
)
|
| 456 |
+
return true
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
return false
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
export async function handleTelegramCallback(
|
| 463 |
+
event: TelegramCallbackEvent,
|
| 464 |
+
store: AppStateStore,
|
| 465 |
+
): Promise<boolean> {
|
| 466 |
+
if (event.data.startsWith(`${MODEL_PREFIX}:select:`)) {
|
| 467 |
+
const index = Number(event.data.split(':').at(-1))
|
| 468 |
+
const options = pendingModelMenus.get(event.chatId)
|
| 469 |
+
const option = Number.isFinite(index) ? options?.[index] : undefined
|
| 470 |
+
|
| 471 |
+
await telegramService.answerCallbackQuery(
|
| 472 |
+
event.callbackQueryId,
|
| 473 |
+
option ? `已选择 ${option.label}` : '模型选项已失效',
|
| 474 |
+
)
|
| 475 |
+
|
| 476 |
+
if (!option) {
|
| 477 |
+
return true
|
| 478 |
+
}
|
| 479 |
+
|
| 480 |
+
const message = applyModelSelection(store, option.value)
|
| 481 |
+
await telegramService.editMessage(event.chatId, event.messageId, message)
|
| 482 |
+
pendingModelMenus.delete(event.chatId)
|
| 483 |
+
return true
|
| 484 |
+
}
|
| 485 |
+
|
| 486 |
+
if (event.data.startsWith(`${CONNECT_PREFIX}:provider:`)) {
|
| 487 |
+
const providerId = event.data.split(':').at(-1)
|
| 488 |
+
await telegramService.answerCallbackQuery(event.callbackQueryId)
|
| 489 |
+
|
| 490 |
+
switch (providerId) {
|
| 491 |
+
case 'github-copilot':
|
| 492 |
+
await telegramService.editMessage(
|
| 493 |
+
event.chatId,
|
| 494 |
+
event.messageId,
|
| 495 |
+
'GitHub Copilot 正在发起设备授权,请稍候...',
|
| 496 |
+
)
|
| 497 |
+
await beginCopilotOAuth(event.chatId)
|
| 498 |
+
return true
|
| 499 |
+
case 'openrouter':
|
| 500 |
+
pendingConnectInputs.set(event.chatId, { kind: 'openrouter-api-key' })
|
| 501 |
+
await telegramService.editMessage(
|
| 502 |
+
event.chatId,
|
| 503 |
+
event.messageId,
|
| 504 |
+
'请直接发送 OpenRouter API Key。',
|
| 505 |
+
)
|
| 506 |
+
return true
|
| 507 |
+
case 'custom-openai':
|
| 508 |
+
pendingConnectInputs.set(event.chatId, { kind: 'custom-openai-base' })
|
| 509 |
+
await telegramService.editMessage(
|
| 510 |
+
event.chatId,
|
| 511 |
+
event.messageId,
|
| 512 |
+
'请直接发送 Custom OpenAI 的 Base URL。',
|
| 513 |
+
)
|
| 514 |
+
return true
|
| 515 |
+
case 'custom-anthropic':
|
| 516 |
+
pendingConnectInputs.set(event.chatId, { kind: 'custom-anthropic-base' })
|
| 517 |
+
await telegramService.editMessage(
|
| 518 |
+
event.chatId,
|
| 519 |
+
event.messageId,
|
| 520 |
+
'请直接发送 Custom Anthropic 的 Base URL。',
|
| 521 |
+
)
|
| 522 |
+
return true
|
| 523 |
+
default:
|
| 524 |
+
await telegramService.editMessage(
|
| 525 |
+
event.chatId,
|
| 526 |
+
event.messageId,
|
| 527 |
+
'暂不支持这个 provider。',
|
| 528 |
+
)
|
| 529 |
+
return true
|
| 530 |
+
}
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
if (event.data.startsWith(`${CONNECT_PREFIX}:skipkey:`)) {
|
| 534 |
+
const providerId = event.data.split(':').at(-1)
|
| 535 |
+
const pending = pendingConnectInputs.get(event.chatId)
|
| 536 |
+
await telegramService.answerCallbackQuery(event.callbackQueryId)
|
| 537 |
+
|
| 538 |
+
if (
|
| 539 |
+
providerId === 'custom-openai' &&
|
| 540 |
+
pending?.kind === 'custom-openai-key'
|
| 541 |
+
) {
|
| 542 |
+
pendingConnectInputs.delete(event.chatId)
|
| 543 |
+
await telegramService.editMessage(
|
| 544 |
+
event.chatId,
|
| 545 |
+
event.messageId,
|
| 546 |
+
'正在获取 Custom OpenAI 模型列表...',
|
| 547 |
+
)
|
| 548 |
+
await startCustomProviderModelFetch(
|
| 549 |
+
event.chatId,
|
| 550 |
+
'custom-openai',
|
| 551 |
+
pending.baseUrl,
|
| 552 |
+
)
|
| 553 |
+
return true
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
if (
|
| 557 |
+
providerId === 'custom-anthropic' &&
|
| 558 |
+
pending?.kind === 'custom-anthropic-key'
|
| 559 |
+
) {
|
| 560 |
+
pendingConnectInputs.delete(event.chatId)
|
| 561 |
+
await telegramService.editMessage(
|
| 562 |
+
event.chatId,
|
| 563 |
+
event.messageId,
|
| 564 |
+
'正在获取 Custom Anthropic 模型列表...',
|
| 565 |
+
)
|
| 566 |
+
await startCustomProviderModelFetch(
|
| 567 |
+
event.chatId,
|
| 568 |
+
'custom-anthropic',
|
| 569 |
+
pending.baseUrl,
|
| 570 |
+
)
|
| 571 |
+
return true
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
return true
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
if (event.data.startsWith(`${CONNECT_PREFIX}:model:`)) {
|
| 578 |
+
const [, , , providerId, indexRaw] = event.data.split(':')
|
| 579 |
+
const selection = pendingConnectModelMenus.get(event.chatId)
|
| 580 |
+
const index = Number(indexRaw)
|
| 581 |
+
const model = Number.isFinite(index) ? selection?.models[index] : undefined
|
| 582 |
+
|
| 583 |
+
await telegramService.answerCallbackQuery(
|
| 584 |
+
event.callbackQueryId,
|
| 585 |
+
model ? `已选择 ${model}` : '模型选项已失效',
|
| 586 |
+
)
|
| 587 |
+
|
| 588 |
+
if (
|
| 589 |
+
!selection ||
|
| 590 |
+
!model ||
|
| 591 |
+
(providerId !== 'custom-openai' && providerId !== 'custom-anthropic')
|
| 592 |
+
) {
|
| 593 |
+
return true
|
| 594 |
+
}
|
| 595 |
+
|
| 596 |
+
if (providerId !== selection.providerId) {
|
| 597 |
+
return true
|
| 598 |
+
}
|
| 599 |
+
|
| 600 |
+
saveGlobalConfig(current => ({
|
| 601 |
+
...current,
|
| 602 |
+
connectedProviders: {
|
| 603 |
+
...(current.connectedProviders || {}),
|
| 604 |
+
[providerId]: {
|
| 605 |
+
baseUrl: selection.baseUrl,
|
| 606 |
+
defaultModel: model,
|
| 607 |
+
...(selection.apiKey ? { apiKey: selection.apiKey } : {}),
|
| 608 |
+
connectedAt: new Date().toISOString(),
|
| 609 |
+
},
|
| 610 |
+
},
|
| 611 |
+
activeProvider: providerId,
|
| 612 |
+
...(providerId === 'custom-openai'
|
| 613 |
+
? {
|
| 614 |
+
openaiCustomModelsCache: selection.models.map(id => ({ id })),
|
| 615 |
+
}
|
| 616 |
+
: {
|
| 617 |
+
anthropicCustomModelsCache: selection.models.map(id => ({ id })),
|
| 618 |
+
}),
|
| 619 |
+
}))
|
| 620 |
+
|
| 621 |
+
pendingConnectModelMenus.delete(event.chatId)
|
| 622 |
+
await telegramService.editMessage(
|
| 623 |
+
event.chatId,
|
| 624 |
+
event.messageId,
|
| 625 |
+
`${providerId === 'custom-openai' ? 'Custom OpenAI' : 'Custom Anthropic'} 已连接成功,默认模型为 ${model}。`,
|
| 626 |
+
)
|
| 627 |
+
return true
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
return false
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
export function clearTelegramInteractiveState(chatId?: string): void {
|
| 634 |
+
if (chatId) {
|
| 635 |
+
pendingConnectInputs.delete(chatId)
|
| 636 |
+
pendingModelMenus.delete(chatId)
|
| 637 |
+
pendingConnectModelMenus.delete(chatId)
|
| 638 |
+
return
|
| 639 |
+
}
|
| 640 |
+
|
| 641 |
+
pendingConnectInputs.clear()
|
| 642 |
+
pendingModelMenus.clear()
|
| 643 |
+
pendingConnectModelMenus.clear()
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
export function logTelegramInteractiveError(error: unknown): void {
|
| 647 |
+
logForDebugging(
|
| 648 |
+
`[telegram] interactive handler failed: ${
|
| 649 |
+
error instanceof Error ? error.message : String(error)
|
| 650 |
+
}`,
|
| 651 |
+
{ level: 'error' },
|
| 652 |
+
)
|
| 653 |
+
}
|
src/services/telegram/telegramConfig.ts
CHANGED
|
@@ -12,6 +12,14 @@ export function getTelegramConfig(): TelegramConfig | undefined {
|
|
| 12 |
return getGlobalConfig().telegram
|
| 13 |
}
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
export function saveTelegramConfig(updates: TelegramConfigDraft): void {
|
| 16 |
saveGlobalConfig(current => ({
|
| 17 |
...current,
|
|
|
|
| 12 |
return getGlobalConfig().telegram
|
| 13 |
}
|
| 14 |
|
| 15 |
+
export function hasTelegramRuntimeConfig(): boolean {
|
| 16 |
+
const telegram = getTelegramConfig()
|
| 17 |
+
return Boolean(
|
| 18 |
+
telegram?.botToken?.trim() &&
|
| 19 |
+
(telegram.allowedUserIds?.filter(Boolean).length ?? 0) > 0,
|
| 20 |
+
)
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
export function saveTelegramConfig(updates: TelegramConfigDraft): void {
|
| 24 |
saveGlobalConfig(current => ({
|
| 25 |
...current,
|
src/services/telegram/telegramTypes.ts
CHANGED
|
@@ -42,6 +42,22 @@ export type TelegramUpdate = {
|
|
| 42 |
is_bot?: boolean
|
| 43 |
}
|
| 44 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
}
|
| 46 |
|
| 47 |
export type TelegramGetMeResponse = {
|
|
@@ -64,6 +80,47 @@ export type TelegramGetUpdatesResponse = {
|
|
| 64 |
export type TelegramSendMessageResponse = {
|
| 65 |
ok: boolean
|
| 66 |
description?: string
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
|
| 69 |
export type TelegramConfigDraft = Partial<TelegramConfig>
|
|
|
|
| 42 |
is_bot?: boolean
|
| 43 |
}
|
| 44 |
}
|
| 45 |
+
callback_query?: {
|
| 46 |
+
id: string
|
| 47 |
+
data?: string
|
| 48 |
+
from?: {
|
| 49 |
+
id?: number
|
| 50 |
+
is_bot?: boolean
|
| 51 |
+
}
|
| 52 |
+
message?: {
|
| 53 |
+
message_id: number
|
| 54 |
+
chat?: {
|
| 55 |
+
id?: number
|
| 56 |
+
type?: string
|
| 57 |
+
}
|
| 58 |
+
text?: string
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
}
|
| 62 |
|
| 63 |
export type TelegramGetMeResponse = {
|
|
|
|
| 80 |
export type TelegramSendMessageResponse = {
|
| 81 |
ok: boolean
|
| 82 |
description?: string
|
| 83 |
+
result?: {
|
| 84 |
+
message_id: number
|
| 85 |
+
}
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export type TelegramSetMyCommandsResponse = {
|
| 89 |
+
ok: boolean
|
| 90 |
+
description?: string
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
export type TelegramEditMessageResponse = {
|
| 94 |
+
ok: boolean
|
| 95 |
+
description?: string
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export type TelegramAnswerCallbackQueryResponse = {
|
| 99 |
+
ok: boolean
|
| 100 |
+
description?: string
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
export type TelegramBotCommand = {
|
| 104 |
+
command: string
|
| 105 |
+
description: string
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
export type TelegramInlineKeyboardButton = {
|
| 109 |
+
text: string
|
| 110 |
+
callback_data: string
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
export type TelegramInlineKeyboardMarkup = {
|
| 114 |
+
inline_keyboard: TelegramInlineKeyboardButton[][]
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
export type TelegramCallbackEvent = {
|
| 118 |
+
kind: 'callback-query'
|
| 119 |
+
callbackQueryId: string
|
| 120 |
+
chatId: string
|
| 121 |
+
userId: string
|
| 122 |
+
messageId: number
|
| 123 |
+
data: string
|
| 124 |
}
|
| 125 |
|
| 126 |
export type TelegramConfigDraft = Partial<TelegramConfig>
|