mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
/**
|
||
* Telegram Adapter for Claude Code Desktop
|
||
*
|
||
* 基于 grammY 的轻量 Telegram Bot,直连服务端 /ws/:sessionId。
|
||
* 启动:TELEGRAM_BOT_TOKEN=xxx bun run telegram/index.ts
|
||
*/
|
||
|
||
import { Bot, InlineKeyboard, type Context } from 'grammy'
|
||
import { WsBridge, type ServerMessage } from '../common/ws-bridge.js'
|
||
import { MessageBuffer } from '../common/message-buffer.js'
|
||
import { MessageDedup } from '../common/message-dedup.js'
|
||
import { enqueue } from '../common/chat-queue.js'
|
||
import { loadConfig } from '../common/config.js'
|
||
import { splitMessage, formatToolUse, formatPermissionRequest } from '../common/format.js'
|
||
import { SessionStore } from '../common/session-store.js'
|
||
import { AdapterHttpClient } from '../common/http-client.js'
|
||
import { isPaired, tryPair } from '../common/pairing.js'
|
||
|
||
const TELEGRAM_TEXT_LIMIT = 4000 // leave margin below 4096
|
||
|
||
// ---------- init ----------
|
||
|
||
const config = loadConfig()
|
||
if (!config.telegram.botToken) {
|
||
console.error('[Telegram] Missing TELEGRAM_BOT_TOKEN. Set env or ~/.claude/adapters.json')
|
||
process.exit(1)
|
||
}
|
||
|
||
const bot = new Bot(config.telegram.botToken)
|
||
const bridge = new WsBridge(config.serverUrl, 'tg')
|
||
const dedup = new MessageDedup()
|
||
const sessionStore = new SessionStore()
|
||
const httpClient = new AdapterHttpClient(config.serverUrl)
|
||
|
||
// Track placeholder messages for streaming updates
|
||
const placeholders = new Map<string, { chatId: string; messageId: number }>()
|
||
// Track accumulated text per chat for streaming
|
||
const accumulatedText = new Map<string, string>()
|
||
// Message buffers per chat
|
||
const buffers = new Map<string, MessageBuffer>()
|
||
// Track chats waiting for project selection
|
||
const pendingProjectSelection = new Map<string, boolean>()
|
||
|
||
// ---------- helpers ----------
|
||
|
||
function isAllowedUser(userId: number): boolean {
|
||
try {
|
||
const cfgFile = JSON.parse(
|
||
require('node:fs').readFileSync(
|
||
require('node:path').join(
|
||
process.env.CLAUDE_CONFIG_DIR || require('node:path').join(require('node:os').homedir(), '.claude'),
|
||
'adapters.json'
|
||
),
|
||
'utf-8'
|
||
)
|
||
)
|
||
return isPaired('telegram', userId, cfgFile)
|
||
} catch {
|
||
return false
|
||
}
|
||
}
|
||
|
||
function getBuffer(chatId: string): MessageBuffer {
|
||
let buf = buffers.get(chatId)
|
||
if (!buf) {
|
||
buf = new MessageBuffer(async (text, isComplete) => {
|
||
await flushToTelegram(chatId, text, isComplete)
|
||
})
|
||
buffers.set(chatId, buf)
|
||
}
|
||
return buf
|
||
}
|
||
|
||
async function flushToTelegram(chatId: string, newText: string, isComplete: boolean): Promise<void> {
|
||
const numericChatId = Number(chatId)
|
||
const prev = accumulatedText.get(chatId) ?? ''
|
||
const fullText = prev + newText
|
||
accumulatedText.set(chatId, fullText)
|
||
|
||
const placeholder = placeholders.get(chatId)
|
||
|
||
if (placeholder) {
|
||
if (isComplete) {
|
||
const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
|
||
try {
|
||
await bot.api.editMessageText(numericChatId, placeholder.messageId, chunks[0]!)
|
||
} catch { /* ignore */ }
|
||
for (let i = 1; i < chunks.length; i++) {
|
||
await bot.api.sendMessage(numericChatId, chunks[i]!)
|
||
}
|
||
} else {
|
||
const displayText = fullText.slice(0, TELEGRAM_TEXT_LIMIT - 2) + ' ▍'
|
||
try {
|
||
await bot.api.editMessageText(numericChatId, placeholder.messageId, displayText)
|
||
} catch { /* ignore */ }
|
||
}
|
||
} else if (isComplete && fullText.trim()) {
|
||
const chunks = splitMessage(fullText, TELEGRAM_TEXT_LIMIT)
|
||
for (const chunk of chunks) {
|
||
await bot.api.sendMessage(numericChatId, chunk)
|
||
}
|
||
}
|
||
|
||
if (isComplete) {
|
||
placeholders.delete(chatId)
|
||
accumulatedText.delete(chatId)
|
||
buffers.get(chatId)?.reset()
|
||
}
|
||
}
|
||
|
||
// ---------- session management ----------
|
||
|
||
async function ensureSession(chatId: string): Promise<boolean> {
|
||
if (bridge.hasSession(chatId)) return true
|
||
|
||
const stored = sessionStore.get(chatId)
|
||
if (stored) {
|
||
bridge.connectSession(chatId, stored.sessionId)
|
||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||
return true
|
||
}
|
||
|
||
const workDir = config.defaultProjectDir
|
||
if (workDir) {
|
||
return await createSessionForChat(chatId, workDir)
|
||
}
|
||
|
||
await showProjectPicker(chatId)
|
||
return false
|
||
}
|
||
|
||
async function createSessionForChat(chatId: string, workDir: string): Promise<boolean> {
|
||
const numericChatId = Number(chatId)
|
||
try {
|
||
const sessionId = await httpClient.createSession(workDir)
|
||
sessionStore.set(chatId, sessionId, workDir)
|
||
bridge.connectSession(chatId, sessionId)
|
||
bridge.onServerMessage(chatId, (msg) => handleServerMessage(chatId, msg))
|
||
return true
|
||
} catch (err) {
|
||
await bot.api.sendMessage(numericChatId,
|
||
`❌ 无法创建会话: ${err instanceof Error ? err.message : String(err)}`)
|
||
return false
|
||
}
|
||
}
|
||
|
||
async function showProjectPicker(chatId: string): Promise<void> {
|
||
const numericChatId = Number(chatId)
|
||
try {
|
||
const projects = await httpClient.listRecentProjects()
|
||
if (projects.length === 0) {
|
||
await bot.api.sendMessage(numericChatId,
|
||
'没有找到最近的项目。请先在 Desktop App 中打开一个项目,或在 Settings → IM 接入中配置默认项目。')
|
||
return
|
||
}
|
||
|
||
const lines = projects.slice(0, 10).map((p, i) =>
|
||
`${i + 1}. ${p.projectName}${p.branch ? ` (${p.branch})` : ''}\n ${p.realPath}`
|
||
)
|
||
pendingProjectSelection.set(chatId, true)
|
||
await bot.api.sendMessage(numericChatId,
|
||
`选择项目(回复编号):\n\n${lines.join('\n\n')}`)
|
||
} catch (err) {
|
||
await bot.api.sendMessage(numericChatId,
|
||
`❌ 无法获取项目列表: ${err instanceof Error ? err.message : String(err)}`)
|
||
}
|
||
}
|
||
|
||
// ---------- server message handler ----------
|
||
|
||
async function handleServerMessage(chatId: string, msg: ServerMessage): Promise<void> {
|
||
const numericChatId = Number(chatId)
|
||
const buf = getBuffer(chatId)
|
||
|
||
switch (msg.type) {
|
||
case 'connected':
|
||
break
|
||
|
||
case 'status':
|
||
if (msg.state === 'thinking' && !placeholders.has(chatId)) {
|
||
const sent = await bot.api.sendMessage(numericChatId, '💭 思考中...')
|
||
placeholders.set(chatId, { chatId, messageId: sent.message_id })
|
||
accumulatedText.set(chatId, '')
|
||
}
|
||
break
|
||
|
||
case 'content_start':
|
||
if (msg.blockType === 'text' && !placeholders.has(chatId)) {
|
||
const sent = await bot.api.sendMessage(numericChatId, '▍')
|
||
placeholders.set(chatId, { chatId, messageId: sent.message_id })
|
||
accumulatedText.set(chatId, '')
|
||
}
|
||
break
|
||
|
||
case 'content_delta':
|
||
if (msg.text) {
|
||
buf.append(msg.text)
|
||
}
|
||
break
|
||
|
||
case 'thinking':
|
||
if (placeholders.has(chatId)) {
|
||
try {
|
||
await bot.api.editMessageText(
|
||
numericChatId,
|
||
placeholders.get(chatId)!.messageId,
|
||
`💭 ${msg.text.slice(0, 200)}...`,
|
||
)
|
||
} catch { /* ignore */ }
|
||
}
|
||
break
|
||
|
||
case 'tool_use_complete': {
|
||
const info = formatToolUse(msg.toolName, msg.input)
|
||
await bot.api.sendMessage(numericChatId, info)
|
||
break
|
||
}
|
||
|
||
case 'tool_result':
|
||
if (msg.isError) {
|
||
await bot.api.sendMessage(numericChatId, `❌ 工具执行失败`)
|
||
}
|
||
break
|
||
|
||
case 'permission_request': {
|
||
const text = formatPermissionRequest(msg.toolName, msg.input, msg.requestId)
|
||
const keyboard = new InlineKeyboard()
|
||
.text('✅ 允许', `permit:${msg.requestId}:yes`)
|
||
.text('❌ 拒绝', `permit:${msg.requestId}:no`)
|
||
await bot.api.sendMessage(numericChatId, text, { reply_markup: keyboard })
|
||
break
|
||
}
|
||
|
||
case 'message_complete':
|
||
await buf.complete()
|
||
break
|
||
|
||
case 'error':
|
||
await bot.api.sendMessage(numericChatId, `❌ ${msg.message}`)
|
||
break
|
||
}
|
||
}
|
||
|
||
// ---------- bot handlers ----------
|
||
|
||
bot.command('start', (ctx) => {
|
||
ctx.reply(
|
||
'👋 Claude Code Bot 已就绪。\n\n' +
|
||
'命令:\n' +
|
||
'/projects — 选择/切换项目\n' +
|
||
'/new — 新建会话\n' +
|
||
'/stop — 停止生成'
|
||
)
|
||
})
|
||
|
||
bot.command('new', async (ctx) => {
|
||
const chatId = String(ctx.chat.id)
|
||
bridge.resetSession(chatId)
|
||
sessionStore.delete(chatId)
|
||
placeholders.delete(chatId)
|
||
accumulatedText.delete(chatId)
|
||
buffers.get(chatId)?.reset()
|
||
buffers.delete(chatId)
|
||
pendingProjectSelection.delete(chatId)
|
||
await showProjectPicker(chatId)
|
||
})
|
||
|
||
bot.command('projects', async (ctx) => {
|
||
const chatId = String(ctx.chat.id)
|
||
await showProjectPicker(chatId)
|
||
})
|
||
|
||
bot.command('stop', (ctx) => {
|
||
const chatId = String(ctx.chat.id)
|
||
bridge.sendStopGeneration(chatId)
|
||
ctx.reply('⏹ 已发送停止信号。')
|
||
})
|
||
|
||
bot.on('message:text', (ctx) => {
|
||
if (!ctx.from) return
|
||
|
||
// 只处理私聊
|
||
if (ctx.chat.type !== 'private') return
|
||
|
||
if (!dedup.tryRecord(String(ctx.message.message_id))) return
|
||
|
||
const chatId = String(ctx.chat.id)
|
||
const userId = ctx.from.id
|
||
const text = ctx.message.text
|
||
|
||
// 检查配对状态
|
||
if (!isAllowedUser(userId)) {
|
||
// 尝试配对
|
||
const displayName = [ctx.from.first_name, ctx.from.last_name].filter(Boolean).join(' ')
|
||
const success = tryPair(text.trim(), { userId, displayName }, 'telegram')
|
||
if (success) {
|
||
ctx.reply('✅ 配对成功!现在可以开始聊天了。\n\n发送消息即可与 Claude 对话。')
|
||
} else {
|
||
ctx.reply('🔒 未授权。请在 Claude Code 桌面端生成配对码后发送给我。')
|
||
}
|
||
return
|
||
}
|
||
|
||
enqueue(chatId, async () => {
|
||
// Check if user is responding to project selection
|
||
if (pendingProjectSelection.has(chatId)) {
|
||
const num = parseInt(text, 10)
|
||
if (num >= 1) {
|
||
try {
|
||
const projects = await httpClient.listRecentProjects()
|
||
const selected = projects[num - 1]
|
||
if (selected) {
|
||
pendingProjectSelection.delete(chatId)
|
||
await createSessionForChat(chatId, selected.realPath)
|
||
await bot.api.sendMessage(Number(chatId),
|
||
`✅ 已选择 ${selected.projectName}。现在可以开始对话了。`)
|
||
return
|
||
}
|
||
} catch { /* fall through */ }
|
||
}
|
||
await bot.api.sendMessage(Number(chatId), '请输入有效的编号。')
|
||
return
|
||
}
|
||
|
||
// Normal message flow
|
||
const ready = await ensureSession(chatId)
|
||
if (ready) {
|
||
bridge.sendUserMessage(chatId, text)
|
||
}
|
||
})
|
||
})
|
||
|
||
bot.on('callback_query:data', async (ctx) => {
|
||
const data = ctx.callbackQuery.data
|
||
if (!data.startsWith('permit:')) return
|
||
|
||
const parts = data.split(':')
|
||
if (parts.length !== 3) return
|
||
|
||
const requestId = parts[1]!
|
||
const allowed = parts[2] === 'yes'
|
||
const chatId = String(ctx.callbackQuery.message?.chat.id)
|
||
|
||
bridge.sendPermissionResponse(chatId, requestId, allowed)
|
||
|
||
const statusText = allowed ? '✅ 已允许' : '❌ 已拒绝'
|
||
try {
|
||
await ctx.editMessageText(
|
||
ctx.callbackQuery.message?.text + `\n\n${statusText}`,
|
||
)
|
||
} catch { /* ignore */ }
|
||
|
||
await ctx.answerCallbackQuery(statusText)
|
||
})
|
||
|
||
// ---------- start ----------
|
||
|
||
console.log('[Telegram] Starting bot...')
|
||
console.log(`[Telegram] Server: ${config.serverUrl}`)
|
||
console.log(`[Telegram] Allowed users: ${config.telegram.allowedUsers.length === 0 ? 'all' : config.telegram.allowedUsers.join(', ')}`)
|
||
|
||
bot.start({
|
||
onStart: () => console.log('[Telegram] Bot is running!'),
|
||
})
|
||
|
||
// Graceful shutdown
|
||
process.on('SIGINT', () => {
|
||
console.log('[Telegram] Shutting down...')
|
||
bot.stop()
|
||
bridge.destroy()
|
||
dedup.destroy()
|
||
process.exit(0)
|
||
})
|