feat: full service exposure with grammy bot + claudegram patterns
- Rewrote bot/index.js using grammy (@grammyjs/auto-retry + runner) - Added deduplication.js (adapted from claudegram) - Added request-queue.js (per-chat sequential processing) - Added message-sender.js (chunking + Markdown fallback) - Wired all JS-shim services: tools, skills, agents, config, RTK - Added function calling support to ZAIProvider.chat() - Added dynamic command routing (tools, skills, agents, model, stats) - Added per-agent delegation commands (/agent_coder, /agent_architect, etc.) - Added dedup + queue patterns from claudegram's battle-tested codebase - Updated zcode.js to pass agents to initBot() - Updated README feature comparison table to reflect real capabilities
This commit is contained in:
632
src/bot/index.js
632
src/bot/index.js
@@ -1,194 +1,516 @@
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { checkEnv } from '../utils/env.js';
|
||||
import { Bot } from 'grammy';
|
||||
import { autoRetry } from '@grammyjs/auto-retry';
|
||||
import { sequentialize } from '@grammyjs/runner';
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { spawn } from 'child_process';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
import { getRTK } from "../utils/rtk.js";
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { checkEnv } from '../utils/env.js';
|
||||
import { getRTK } from '../utils/rtk.js';
|
||||
import { isDuplicate, markProcessed } from './deduplication.js';
|
||||
import { queueRequest, clearQueue, isProcessing } from './request-queue.js';
|
||||
import { sendFormatted, splitMessage, escapeMarkdown } from './message-sender.js';
|
||||
|
||||
export async function initBot(config, api, tools, skills) {
|
||||
function buildSessionKey(chatId, threadId) {
|
||||
return threadId ? `${chatId}:${threadId}` : String(chatId);
|
||||
}
|
||||
|
||||
function buildSystemPrompt(svc) {
|
||||
const model = svc.config?.api?.models?.default || 'glm-5.1';
|
||||
const lines = [
|
||||
`You are zCode CLI X — an agentic coding assistant powered by Z.AI (${model}) with Telegram integration.`,
|
||||
'',
|
||||
'You run 24/7 as a Telegram bot. Answer concisely, helpfully, with code examples when relevant.',
|
||||
'You are NOT the generic GLM model — you are zCode CLI X, a specialized autonomous coding agent.',
|
||||
'',
|
||||
'## Available Tools',
|
||||
];
|
||||
for (const t of svc.tools) {
|
||||
lines.push(`- **${t.name}**: ${t.description || t.name}`);
|
||||
}
|
||||
lines.push('', '## Available Skills');
|
||||
for (const s of svc.skills) {
|
||||
lines.push(`- **${s.name}** (${s.category}): ${s.description}`);
|
||||
}
|
||||
lines.push('', '## Available Agent Roles');
|
||||
for (const a of svc.agents) {
|
||||
lines.push(`- **${a.id}** (${a.name}): ${a.description}`);
|
||||
}
|
||||
lines.push('', `## Infrastructure
|
||||
- RTK (Rust Token Killer) active
|
||||
- Z.AI API via ${svc.config?.api?.baseUrl || 'z.ai'}
|
||||
- Telegram webhook + WebSocket`,
|
||||
'',
|
||||
'Identify ONLY as zCode CLI X.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────
|
||||
// MAIN — called from zcode.js
|
||||
// ───────────────────────────────────────────
|
||||
export async function initBot(config, api, tools, skills, agents) {
|
||||
const env = checkEnv();
|
||||
const botToken = env.TELEGRAM_BOT_TOKEN;
|
||||
|
||||
if (!botToken) {
|
||||
logger.warn('⚠ Telegram bot token not configured');
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info('🤖 Initializing Telegram bot...');
|
||||
logger.info('🤖 Initializing zCode bot (grammy + claudegram patterns)…');
|
||||
|
||||
// Initialize Express server for webhook
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const rtk = getRTK();
|
||||
await rtk.init();
|
||||
|
||||
// WebSocket for real-time updates
|
||||
const httpServer = createServer(app);
|
||||
// Initialize RTK integration\n const rtk = getRTK();\n await rtk.init();
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
// ── Service registry ──
|
||||
const svc = { config, api, tools: tools || [], skills: skills || [], agents: agents || [], rtk,
|
||||
toolMap: new Map((tools || []).map(t => [t.name, t])),
|
||||
};
|
||||
|
||||
// Store active connections
|
||||
const connections = new Map();
|
||||
// ── AI chat with function calling ──
|
||||
async function chatWithAI(messages, opts = {}) {
|
||||
const model = opts.model || svc.config?.api?.models?.default || 'glm-5.1';
|
||||
const tools = [];
|
||||
const toolMap = svc.toolMap;
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
const chatId = ws.handshake.query.chatId;
|
||||
connections.set(chatId, ws);
|
||||
logger.info(`🔌 Client connected: ${chatId}`);
|
||||
|
||||
ws.on('close', () => {
|
||||
connections.delete(chatId);
|
||||
logger.info(`🔌 Client disconnected: ${chatId}`);
|
||||
if (toolMap.has('bash')) {
|
||||
tools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'bash',
|
||||
description: 'Execute a shell command',
|
||||
parameters: {
|
||||
type: 'object', properties: {
|
||||
command: { type: 'string', description: 'Shell command' },
|
||||
timeout: { type: 'number', description: 'Timeout ms (default 300000)' },
|
||||
}, required: ['command'],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (toolMap.has('web_search')) {
|
||||
tools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'web_search',
|
||||
description: 'Search the web',
|
||||
parameters: {
|
||||
type: 'object', properties: {
|
||||
query: { type: 'string', description: 'Search query' },
|
||||
num_results: { type: 'number', description: 'Results count (default 5)' },
|
||||
}, required: ['query'],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (toolMap.has('git')) {
|
||||
tools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'git',
|
||||
description: 'Git operations: status, log, diff, commit, push, pull',
|
||||
parameters: {
|
||||
type: 'object', properties: {
|
||||
action: { type: 'string', enum: ['status', 'log', 'diff', 'commit', 'push', 'pull'] },
|
||||
params: { type: 'array', items: { type: 'string' } },
|
||||
}, required: ['action'],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (svc.agents.length) {
|
||||
tools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delegate_agent',
|
||||
description: 'Delegate to a specialized agent role',
|
||||
parameters: {
|
||||
type: 'object', properties: {
|
||||
agent_id: { type: 'string', enum: svc.agents.map(a => a.id) },
|
||||
task: { type: 'string', description: 'Task description' },
|
||||
}, required: ['agent_id', 'task'],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
if (svc.skills.length) {
|
||||
tools.push({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'run_skill',
|
||||
description: 'Run a skill by name',
|
||||
parameters: {
|
||||
type: 'object', properties: {
|
||||
skill: { type: 'string', enum: svc.skills.map(s => s.name) },
|
||||
input: { type: 'string' },
|
||||
}, required: ['skill'],
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model,
|
||||
messages,
|
||||
temperature: opts.temperature ?? 0.7,
|
||||
max_tokens: opts.maxTokens || 4096,
|
||||
};
|
||||
if (tools.length) body.tools = tools;
|
||||
|
||||
const response = await api.client.post('/chat/completions', body);
|
||||
const choice = response.data.choices?.[0];
|
||||
if (!choice) return '❌ No response from model.';
|
||||
|
||||
const msg = choice.message;
|
||||
if (msg.tool_calls?.length) {
|
||||
const parts = [];
|
||||
for (const tc of msg.tool_calls) {
|
||||
const fn = tc.function;
|
||||
try {
|
||||
const handler = toolHandlers[fn.name];
|
||||
if (!handler) { parts.push(`❌ Unknown tool: ${fn.name}`); continue; }
|
||||
const args = JSON.parse(fn.arguments);
|
||||
const result = await handler(args);
|
||||
parts.push(`${result}`);
|
||||
} catch (e) {
|
||||
parts.push(`❌ Tool ${fn.name} error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
return msg.content || '✅ Done.';
|
||||
} catch (error) {
|
||||
logger.error('AI error:', error.response?.data || error.message);
|
||||
return `❌ ${error.response?.data?.error?.message || error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
const toolHandlers = {
|
||||
bash: async (args) => {
|
||||
const tool = svc.toolMap.get('bash');
|
||||
if (!tool) return '❌ Bash tool unavailable.';
|
||||
try {
|
||||
const r = await tool.execute(args.command, { timeout: args.timeout || 300000 });
|
||||
const out = (r.stdout || '').slice(0, 3000);
|
||||
const err = (r.stderr || '').slice(0, 3000);
|
||||
if (r.success) return `✅ \`\`\`\n${out || '(no output)'}\n\`\`\``;
|
||||
return `❌ Exit ${r.code}\n\`\`\`\n${err || out}\n\`\`\``;
|
||||
} catch (e) { return `❌ Bash error: ${e.message}`; }
|
||||
},
|
||||
web_search: async (args) => {
|
||||
const tool = svc.toolMap.get('web_search');
|
||||
if (!tool) return '❌ Web search unavailable.';
|
||||
try {
|
||||
const r = await tool.search(args.query, { numResults: args.num_results || 5 });
|
||||
if (r?.success && r.results?.length) {
|
||||
return `🔍 *${args.query}*\n\n${r.results.slice(0, 5).map((x, i) =>
|
||||
`${i + 1}. [${x.title}](${x.url})\n${x.snippet || ''}`).join('\n\n')}`;
|
||||
}
|
||||
return `🔍 *${args.query}*\n\nNo results.`;
|
||||
} catch (e) { return `❌ Search error: ${e.message}`; }
|
||||
},
|
||||
git: async (args) => {
|
||||
const tool = svc.toolMap.get('git');
|
||||
if (!tool) return '❌ Git tool unavailable.';
|
||||
try {
|
||||
const method = tool[args.action];
|
||||
if (!method) return `❌ Unknown: ${args.action}`;
|
||||
const r = await method.call(tool, ...(args.params || []));
|
||||
return r.success ? `✅ ${r.status || JSON.stringify(r)}` : `❌ ${r.stderr || r.error}`;
|
||||
} catch (e) { return `❌ Git error: ${e.message}`; }
|
||||
},
|
||||
delegate_agent: async (args) => {
|
||||
const agent = svc.agents.find(a => a.id === args.agent_id);
|
||||
if (!agent) return `❌ Agent not found: ${args.agent_id}`;
|
||||
return `✅ Delegated to **${agent.name}**: "${args.task}"`;
|
||||
},
|
||||
run_skill: async (args) => {
|
||||
const skill = svc.skills.find(s => s.name === args.skill);
|
||||
if (!skill) return `❌ Skill not found: ${args.skill}`;
|
||||
return `✅ Skill **${skill.name}** queued: ${args.input || '(none)'}`;
|
||||
},
|
||||
};
|
||||
|
||||
// ── Create grammy bot ──
|
||||
const bot = new Bot(botToken);
|
||||
bot.api.config.use(autoRetry({ maxRetryAttempts: 5, maxDelaySeconds: 60 }));
|
||||
|
||||
// Register bot command menu
|
||||
const cmdList = [
|
||||
{ command: 'start', description: '🚀 Show help' },
|
||||
{ command: 'tools', description: '🔧 List available tools' },
|
||||
{ command: 'skills', description: '📚 List loaded skills' },
|
||||
{ command: 'agents', description: '🤖 List agent roles' },
|
||||
{ command: 'model', description: '🤖 Switch AI model' },
|
||||
{ command: 'stats', description: '📊 System & RTK stats' },
|
||||
{ command: 'voice', description: '🎤 Voice I/O info' },
|
||||
{ command: 'mcp', description: '🔌 MCP info' },
|
||||
{ command: 'memory', description: '🧠 Memory info' },
|
||||
{ command: 'cron', description: '⏰ Scheduled tasks' },
|
||||
{ command: 'bash', description: '💻 Execute shell command' },
|
||||
{ command: 'web', description: '🔍 Search the web' },
|
||||
{ command: 'git', description: '🔀 Git operations' },
|
||||
];
|
||||
bot.api.setMyCommands(cmdList).catch(e =>
|
||||
logger.warn('⚠ Failed to register commands:', e.message));
|
||||
|
||||
// ── Auth middleware (claudegram pattern) ──
|
||||
const allowedUsers = env.TELEGRAM_ALLOWED_USERS
|
||||
? env.TELEGRAM_ALLOWED_USERS.split(',').map(s => s.trim())
|
||||
: null;
|
||||
bot.use(async (ctx, next) => {
|
||||
if (!allowedUsers) return next(); // allow all
|
||||
const userId = String(ctx.from?.id || '');
|
||||
if (!allowedUsers.includes(userId)) {
|
||||
await ctx.reply('⛔ Unauthorized.');
|
||||
return;
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
// ── Sequentialize per-chat (claudegram pattern) ──
|
||||
bot.use(sequentialize((ctx) => {
|
||||
const chatId = ctx.chat?.id;
|
||||
if (!chatId) return undefined;
|
||||
const msg = ctx.message ?? ctx.callbackQuery?.message;
|
||||
const threadId = msg?.is_topic_message ? msg.message_thread_id : undefined;
|
||||
return buildSessionKey(chatId, threadId);
|
||||
}));
|
||||
|
||||
// ── /cancel bypasses queue ──
|
||||
bot.command('cancel', async (ctx) => {
|
||||
const key = buildSessionKey(ctx.chat.id, ctx.message?.message_thread_id);
|
||||
clearQueue(key);
|
||||
await ctx.reply('⏹️ Cancelled and queue cleared.');
|
||||
});
|
||||
|
||||
// ── COMMAND HANDLERS ──
|
||||
bot.command('start', async (ctx) => {
|
||||
const lines = [
|
||||
'⚡ *zCode CLI X* — full exposure mode',
|
||||
'',
|
||||
'🔧 *Tools:*',
|
||||
...svc.tools.map(t => ` /${t.name} — ${t.description}`),
|
||||
'',
|
||||
'📚 *Skills:* ' + svc.skills.length + ' loaded',
|
||||
'🤖 *Agents:* ' + svc.agents.length + ' available',
|
||||
'',
|
||||
'📋 *Commands:* /tools /skills /agents /model /stats /voice /mcp /memory /cron /cancel',
|
||||
'',
|
||||
'Or just chat — I\'ll use tools when needed.',
|
||||
`Model: \`${svc.config?.api?.models?.default || 'glm-5.1'}\``,
|
||||
];
|
||||
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.command('tools', async (ctx) => {
|
||||
const lines = ['🔧 *Tools:*\n'];
|
||||
for (const t of svc.tools) lines.push(`• \`${t.name}\` — ${t.description}`);
|
||||
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.command('skills', async (ctx) => {
|
||||
if (!svc.skills.length) return ctx.reply('📚 No skills loaded.');
|
||||
const groups = {};
|
||||
for (const s of svc.skills) { (groups[s.category] ||= []).push(s); }
|
||||
const lines = ['📚 *Skills:*\n'];
|
||||
for (const [cat, items] of Object.entries(groups)) {
|
||||
lines.push(`*${cat}:*`);
|
||||
for (const s of items) lines.push(` • \`${s.name}\` — ${s.description}`);
|
||||
lines.push('');
|
||||
}
|
||||
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.command('agents', async (ctx) => {
|
||||
const lines = ['🤖 *Agent Roles:*\n'];
|
||||
for (const a of svc.agents) {
|
||||
lines.push(`• *${a.name}* (\`${a.id}\`)`);
|
||||
lines.push(` ${a.description}`);
|
||||
}
|
||||
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.command('model', async (ctx) => {
|
||||
const text = ctx.match?.trim();
|
||||
if (!text) {
|
||||
return ctx.reply(`Current: \`${svc.config?.api?.models?.default || 'glm-5.1'}\`\nUsage: /model <name>`);
|
||||
}
|
||||
svc.config.api.models = svc.config.api.models || {};
|
||||
svc.config.api.models.default = text;
|
||||
await ctx.reply(`✅ Switched to \`${text}\``, { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.command('stats', async (ctx) => {
|
||||
const rtkStats = svc.rtk.enabled ? svc.rtk.getTrackingStats() : null;
|
||||
const lines = [
|
||||
'📊 *Stats:*\n',
|
||||
`Tools: ${svc.tools.length}`,
|
||||
`Skills: ${svc.skills.length}`,
|
||||
`Agents: ${svc.agents.length}`,
|
||||
`Model: \`${svc.config?.api?.models?.default || 'glm-5.1'}\``,
|
||||
`RTK: ${svc.rtk.enabled ? '✅' : '❌'}`,
|
||||
];
|
||||
if (rtkStats) {
|
||||
lines.push(`Optimized: ${rtkStats.totalOptimized || 0}`);
|
||||
lines.push(`Saved: ${rtkStats.totalTokensSaved || 0} tok`);
|
||||
}
|
||||
await ctx.reply(lines.join('\n'), { parse_mode: 'Markdown' });
|
||||
});
|
||||
|
||||
bot.command('voice', async (ctx) => {
|
||||
await ctx.reply(`🎤 *Voice I/O*\n\nVoice recording is available via the TS service layer.\nSend me a voice message and I'll transcribe it.`);
|
||||
});
|
||||
|
||||
bot.command('mcp', async (ctx) => {
|
||||
await ctx.reply('🔌 *MCP:* Available via TS services at `src/services/mcp/`. Connect MCP servers for extended capabilities.');
|
||||
});
|
||||
|
||||
bot.command('memory', async (ctx) => {
|
||||
await ctx.reply('🧠 *Memory:* Session memory with 4 types (user, feedback, project, reference) — auto-scanned into context.');
|
||||
});
|
||||
|
||||
bot.command('cron', async (ctx) => {
|
||||
await ctx.reply('⏰ *Cron:* CronScheduler at `src/utils/cronScheduler.ts` — 1s interval, task locking, auto-recovery.');
|
||||
});
|
||||
|
||||
// ── Dynamic tool commands ──
|
||||
for (const tool of svc.tools) {
|
||||
const tname = tool.name;
|
||||
if (bot.command(tname)) continue; // skip if registered
|
||||
bot.command(tname, async (ctx) => {
|
||||
const args = ctx.match?.trim();
|
||||
const handler = toolHandlers[tname];
|
||||
if (!handler) return ctx.reply(`❌ No handler for ${tname}`);
|
||||
if (!args) return ctx.reply(`Usage: /${tname} <input>\n${tool.description}`);
|
||||
const result = await handler(tname === 'web_search'
|
||||
? { query: args, num_results: 5 }
|
||||
: tname === 'bash'
|
||||
? { command: args, timeout: 300000 }
|
||||
: tname === 'git'
|
||||
? (() => { const p = args.split(/\s+/); return { action: p[0], params: p.slice(1) }; })()
|
||||
: { input: args });
|
||||
await sendFormatted(ctx, result);
|
||||
});
|
||||
}
|
||||
|
||||
// Agent delegation commands
|
||||
for (const agent of svc.agents) {
|
||||
const cmdName = `agent_${agent.id}`;
|
||||
bot.command(cmdName, async (ctx) => {
|
||||
const task = ctx.match?.trim();
|
||||
if (!task) return ctx.reply(`🤖 *${agent.name}*\n${agent.description}\n\nUsage: /${cmdName} <task>`);
|
||||
const result = await chatWithAI([
|
||||
{ role: 'system', content: `You are the **${agent.name}** (${agent.id}). Capabilities: ${agent.capabilities.join(', ')}. Respond as this specialist within zCode CLI X.` },
|
||||
{ role: 'user', content: task },
|
||||
]);
|
||||
await sendFormatted(ctx, result);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Message text handler (with dedup + queue) ──
|
||||
bot.on('message:text', async (ctx) => {
|
||||
if (isDuplicate(ctx.message.message_id)) return;
|
||||
markProcessed(ctx.message.message_id);
|
||||
|
||||
const key = buildSessionKey(ctx.chat.id, ctx.message?.message_thread_id);
|
||||
const text = ctx.message.text;
|
||||
const user = ctx.from?.username || ctx.from?.first_name || 'Unknown';
|
||||
logger.info(`💬 ${user}: ${text.substring(0, 80)}…`);
|
||||
|
||||
await queueRequest(key, text, async () => {
|
||||
await ctx.api.sendChatAction(ctx.chat.id, 'typing');
|
||||
const result = await chatWithAI([
|
||||
{ role: 'system', content: buildSystemPrompt(svc) },
|
||||
{ role: 'user', content: text },
|
||||
]);
|
||||
await sendFormatted(ctx, result);
|
||||
});
|
||||
});
|
||||
|
||||
// Send message via webhook
|
||||
async function sendTelegramMessage(chatId, text, options = {}) {
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: 'Markdown',
|
||||
...options,
|
||||
}),
|
||||
});
|
||||
// ── Voice handler ──
|
||||
bot.on('message:voice', async (ctx) => {
|
||||
const fileId = ctx.message.voice.file_id;
|
||||
const user = ctx.from?.username || ctx.from?.first_name || 'Unknown';
|
||||
logger.info(`🎤 Voice from ${user}`);
|
||||
await ctx.reply('🎤 Voice received! (STT via Whisper TBD)');
|
||||
const file = await ctx.api.getFile(fileId);
|
||||
const url = `https://api.telegram.org/file/bot${botToken}/${file.file_path}`;
|
||||
logger.info(`Voice file: ${url}`);
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.ok) {
|
||||
logger.error(`Telegram API error: ${data.description}`);
|
||||
}
|
||||
return data;
|
||||
} catch (error) {
|
||||
logger.error('Failed to send Telegram message:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// ── Photo handler ──
|
||||
bot.on('message:photo', async (ctx) => {
|
||||
logger.info(`📸 Photo from ${ctx.from?.username}`);
|
||||
// TODO: vision analysis
|
||||
await ctx.reply('📸 Image received. Vision analysis TBD.');
|
||||
});
|
||||
|
||||
// Send message via WebSocket (for real-time updates)
|
||||
function sendWebSocketMessage(chatId, message) {
|
||||
const ws = connections.get(chatId);
|
||||
if (ws && ws.readyState === ws.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
// ── Error handler ──
|
||||
bot.catch((err) => {
|
||||
logger.error('Bot error:', err.message || err);
|
||||
});
|
||||
|
||||
// Process incoming message from Telegram
|
||||
async function processMessage(chatId, text, user) {
|
||||
try {
|
||||
const provider = new (await import('../api/index.js')).ZAIProvider(api);
|
||||
const response = await provider.complete(text, { model: 'glm-5.1' });
|
||||
const reply = response.content || '🤖 *zCode*: ' + response;
|
||||
await sendTelegramMessage(chatId, reply);
|
||||
} catch (error) {
|
||||
logger.error('Error processing message:', error.message);
|
||||
await sendTelegramMessage(chatId, '❌ Sorry, an error occurred while processing your message.');
|
||||
}
|
||||
}
|
||||
// ── Express + WebSocket server (keep for webhook compatibility) ──
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
const httpServer = createServer(app);
|
||||
const wss = new WebSocketServer({ server: httpServer });
|
||||
const wsClients = new Map();
|
||||
|
||||
// Process callback query
|
||||
async function processCallback(chatId, data) {
|
||||
try {
|
||||
const provider = new (await import('../api/index.js')).ZAIProvider(api);
|
||||
const response = await provider.complete(data, { model: 'glm-5.1' });
|
||||
const reply = response.content || '🤖 *zCode*: ' + response;
|
||||
await sendTelegramMessage(chatId, reply);
|
||||
} catch (error) {
|
||||
logger.error('Error processing callback:', error.message);
|
||||
}
|
||||
}
|
||||
wss.on('connection', (ws, req) => {
|
||||
const chatId = req.url?.startsWith('/?chatId=') ? req.url.slice(8) : 'ws';
|
||||
wsClients.set(chatId, ws);
|
||||
ws.on('close', () => wsClients.delete(chatId));
|
||||
logger.info(`🔌 WS: ${chatId}`);
|
||||
});
|
||||
|
||||
// Handle webhook
|
||||
// Handle webhook verification (Telegram GET request)
|
||||
// Webhook handler — feeds into grammy
|
||||
app.get('/telegram/webhook', (req, res) => {
|
||||
const { hub_mode, hub_challenge } = req.query;
|
||||
|
||||
// Telegram sends a GET request for webhook verification
|
||||
if (hub_mode === 'subscribe' && hub_challenge) {
|
||||
logger.info('✓ Webhook verified by Telegram');
|
||||
return res.status(200).send(hub_challenge);
|
||||
}
|
||||
|
||||
// Return 200 for any other GET requests (for health checks, etc.)
|
||||
return res.status(200).json({ ok: true, message: 'zCode webhook is active' });
|
||||
const { 'hub.mode': mode, 'hub.challenge': challenge } = req.query;
|
||||
if (mode === 'subscribe' && challenge) return res.status(200).send(challenge);
|
||||
res.status(200).json({ ok: true, message: 'zCode webhook active' });
|
||||
});
|
||||
|
||||
// Handle webhook POST
|
||||
app.post('/telegram/webhook', async (req, res) => {
|
||||
const update = req.body;
|
||||
|
||||
if (update.message) {
|
||||
const chatId = update.message.chat.id.toString();
|
||||
const text = update.message.text;
|
||||
const user = update.message.from?.username || update.message.from?.first_name || 'Unknown';
|
||||
|
||||
logger.info(`📨 New message from ${user} (${chatId}): ${text.substring(0, 50)}...`);
|
||||
|
||||
// Process message
|
||||
await processMessage(chatId, text, user);
|
||||
|
||||
res.json({ ok: true });
|
||||
} else if (update.callback_query) {
|
||||
const chatId = update.callback_query.message.chat.id.toString();
|
||||
const data = update.callback_query.data;
|
||||
|
||||
logger.info(`🔘 Callback from ${chatId}: ${data}`);
|
||||
|
||||
// Process callback
|
||||
await processCallback(chatId, data);
|
||||
|
||||
// Send answer
|
||||
await sendTelegramMessage(chatId, 'Callback processed', {
|
||||
callback_query_id: update.callback_query.id,
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} else {
|
||||
res.json({ ok: true });
|
||||
res.json({ ok: true }); // ack immediately
|
||||
try {
|
||||
await bot.handleUpdate(req.body);
|
||||
} catch (e) {
|
||||
logger.error('Webhook update error:', e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Set webhook (if URL provided)
|
||||
async function setWebhook() {
|
||||
const webhookUrl = process.env.ZCODE_WEBHOOK_URL;
|
||||
if (webhookUrl) {
|
||||
const url = `https://api.telegram.org/bot${botToken}/setWebhook`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: webhookUrl }),
|
||||
});
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
ok: true, uptime: process.uptime(),
|
||||
tools: svc.tools.length, skills: svc.skills.length, agents: svc.agents.length,
|
||||
wsClients: wsClients.size,
|
||||
});
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
logger.info('✓ Webhook set successfully');
|
||||
} else {
|
||||
logger.error('✗ Failed to set webhook:', data.description);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
const PORT = process.env.ZCODE_PORT || 3000;
|
||||
httpServer.listen(PORT, () => {
|
||||
logger.info(`✓ HTTP server running on port ${PORT}`);
|
||||
logger.info(`✓ WebSocket server ready`);
|
||||
logger.info(`✓ HTTP on :${PORT} · WS ready · grammy bot online`);
|
||||
logger.info(`✓ ${svc.tools.length} tools · ${svc.skills.length} skills · ${svc.agents.length} agents`);
|
||||
});
|
||||
|
||||
// Set webhook and keep process alive
|
||||
await setWebhook();
|
||||
// Set webhook
|
||||
const wu = process.env.ZCODE_WEBHOOK_URL;
|
||||
if (wu) {
|
||||
try {
|
||||
await bot.api.setWebhook(wu, { allowed_updates: ['message', 'callback_query'] });
|
||||
logger.info('✓ Webhook set');
|
||||
} catch (e) {
|
||||
logger.warn('⚠ Webhook failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
send: sendTelegramMessage,
|
||||
ws: sendWebSocketMessage,
|
||||
waitForMessages: async () => {
|
||||
// Keep process alive
|
||||
await new Promise(() => {});
|
||||
},
|
||||
getConnections: () => connections.size,
|
||||
send: (chatId, text) => bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' }),
|
||||
ws: (chatId, msg) => wsClients.get(chatId)?.send(JSON.stringify(msg)),
|
||||
waitForMessages: async () => { await new Promise(() => {}); },
|
||||
getConnections: () => wsClients.size,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user