Resolve merge conflicts with Mistral provider
Include both Mistral and Ollama providers in factory, config, and env docs.
This commit is contained in:
@@ -4,6 +4,8 @@
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
const TELEGRAM_API = 'https://api.telegram.org';
|
||||
/** Telegram Bot API limit for sendMessage text (bytes/characters). */
|
||||
const TELEGRAM_MAX_TEXT = 4096;
|
||||
|
||||
// ─── Alert Tiers ────────────────────────────────────────────────────────────
|
||||
// FLASH: Immediate action required — market-moving, time-critical (e.g. war escalation, flash crash)
|
||||
@@ -38,6 +40,7 @@ export class TelegramAlerter {
|
||||
this._lastUpdateId = 0; // For polling bot commands
|
||||
this._commandHandlers = {}; // Registered command callbacks
|
||||
this._pollingInterval = null;
|
||||
this._botUsername = null;
|
||||
}
|
||||
|
||||
get isConfigured() {
|
||||
@@ -47,42 +50,70 @@ export class TelegramAlerter {
|
||||
// ─── Core Messaging ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send a message via Telegram Bot API.
|
||||
* Send a message via Telegram Bot API. Splits at TELEGRAM_MAX_TEXT so long messages
|
||||
* (e.g. /brief) are sent in multiple messages instead of being truncated or failing.
|
||||
* @param {string} message - markdown-formatted message
|
||||
* @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId }
|
||||
* @param {object} opts - optional: { parseMode, disablePreview, replyToMessageId, chatId }
|
||||
* @returns {Promise<{ok: boolean, messageId?: number}>}
|
||||
*/
|
||||
async sendMessage(message, opts = {}) {
|
||||
if (!this.isConfigured) return { ok: false };
|
||||
const chatId = opts.chatId ?? this.chatId;
|
||||
const parseMode = opts.parseMode || 'Markdown';
|
||||
const chunks = this._chunkText(message, TELEGRAM_MAX_TEXT);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: this.chatId,
|
||||
text: message,
|
||||
parse_mode: opts.parseMode || 'Markdown',
|
||||
disable_web_page_preview: opts.disablePreview !== false,
|
||||
...(opts.replyToMessageId ? { reply_to_message_id: opts.replyToMessageId } : {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
let lastResult = { ok: false, messageId: undefined };
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/sendMessage`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
chat_id: chatId,
|
||||
text: chunks[i],
|
||||
parse_mode: parseMode,
|
||||
disable_web_page_preview: opts.disablePreview !== false,
|
||||
...(opts.replyToMessageId && i === 0 ? { reply_to_message_id: opts.replyToMessageId } : {}),
|
||||
}),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
|
||||
return { ok: false };
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
console.error(`[Telegram] Send failed (${res.status}): ${err.substring(0, 200)}`);
|
||||
return lastResult;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
lastResult = { ok: true, messageId: data.result?.message_id };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
return { ok: true, messageId: data.result?.message_id };
|
||||
return lastResult;
|
||||
} catch (err) {
|
||||
console.error('[Telegram] Send error:', err.message);
|
||||
return { ok: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into chunks of at most maxLen. Prefer breaking at newlines to avoid
|
||||
* splitting mid-Markdown.
|
||||
*/
|
||||
_chunkText(text, maxLen = TELEGRAM_MAX_TEXT) {
|
||||
if (!text || text.length <= maxLen) return text ? [text] : [];
|
||||
const chunks = [];
|
||||
let start = 0;
|
||||
while (start < text.length) {
|
||||
let end = Math.min(start + maxLen, text.length);
|
||||
if (end < text.length) {
|
||||
const lastNewline = text.lastIndexOf('\n', end - 1);
|
||||
if (lastNewline > start) end = lastNewline + 1;
|
||||
}
|
||||
chunks.push(text.slice(start, end));
|
||||
start = end;
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// Backward-compatible alias
|
||||
async sendAlert(message) {
|
||||
const result = await this.sendMessage(message);
|
||||
@@ -286,6 +317,9 @@ export class TelegramAlerter {
|
||||
if (this._pollingInterval) return; // Already polling
|
||||
|
||||
console.log('[Telegram] Bot command polling started');
|
||||
this._initializeBotCommands().catch((err) => {
|
||||
console.error('[Telegram] Command initialization failed:', err.message);
|
||||
});
|
||||
this._pollingInterval = setInterval(() => this._pollUpdates(), intervalMs);
|
||||
// Initial poll
|
||||
this._pollUpdates();
|
||||
@@ -325,8 +359,8 @@ export class TelegramAlerter {
|
||||
const msg = update.message;
|
||||
if (!msg?.text) continue;
|
||||
|
||||
// Only process messages from the configured chat
|
||||
const chatId = String(msg.chat?.id);
|
||||
// Restrict command execution to the configured chat/group only.
|
||||
if (chatId !== String(this.chatId)) continue;
|
||||
|
||||
await this._handleMessage(msg);
|
||||
@@ -342,8 +376,11 @@ export class TelegramAlerter {
|
||||
async _handleMessage(msg) {
|
||||
const text = msg.text.trim();
|
||||
const parts = text.split(/\s+/);
|
||||
const command = parts[0].toLowerCase();
|
||||
const rawCommand = parts[0].toLowerCase();
|
||||
const command = this._normalizeCommand(rawCommand);
|
||||
if (!command) return;
|
||||
const args = parts.slice(1).join(' ');
|
||||
const replyChatId = msg.chat?.id;
|
||||
|
||||
// Built-in commands
|
||||
if (command === '/help') {
|
||||
@@ -352,7 +389,7 @@ export class TelegramAlerter {
|
||||
.join('\n');
|
||||
await this.sendMessage(
|
||||
`🤖 *CRUCIX BOT COMMANDS*\n\n${helpText}\n\n_Tip: Commands are case-insensitive_`,
|
||||
{ replyToMessageId: msg.message_id }
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -362,7 +399,7 @@ export class TelegramAlerter {
|
||||
this._muteUntil = Date.now() + hours * 60 * 60 * 1000;
|
||||
await this.sendMessage(
|
||||
`🔇 Alerts muted for ${hours}h — until ${new Date(this._muteUntil).toLocaleTimeString()} UTC\nUse /unmute to resume.`,
|
||||
{ replyToMessageId: msg.message_id }
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -371,7 +408,7 @@ export class TelegramAlerter {
|
||||
this._muteUntil = null;
|
||||
await this.sendMessage(
|
||||
`🔔 Alerts resumed. You'll receive the next signal evaluation.`,
|
||||
{ replyToMessageId: msg.message_id }
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -379,7 +416,7 @@ export class TelegramAlerter {
|
||||
if (command === '/alerts') {
|
||||
const recent = this._alertHistory.slice(-10);
|
||||
if (recent.length === 0) {
|
||||
await this.sendMessage('No recent alerts.', { replyToMessageId: msg.message_id });
|
||||
await this.sendMessage('No recent alerts.', { chatId: replyChatId, replyToMessageId: msg.message_id });
|
||||
return;
|
||||
}
|
||||
const lines = recent.map(a =>
|
||||
@@ -387,7 +424,7 @@ export class TelegramAlerter {
|
||||
);
|
||||
await this.sendMessage(
|
||||
`📋 *Recent Alerts (last ${recent.length})*\n\n${lines.join('\n')}`,
|
||||
{ replyToMessageId: msg.message_id }
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -398,19 +435,86 @@ export class TelegramAlerter {
|
||||
try {
|
||||
const response = await handler(args, msg.message_id);
|
||||
if (response) {
|
||||
await this.sendMessage(response, { replyToMessageId: msg.message_id });
|
||||
await this.sendMessage(response, { chatId: replyChatId, replyToMessageId: msg.message_id });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Telegram] Command ${command} error:`, err.message);
|
||||
await this.sendMessage(
|
||||
`❌ Command failed: ${err.message}`,
|
||||
{ replyToMessageId: msg.message_id }
|
||||
{ chatId: replyChatId, replyToMessageId: msg.message_id }
|
||||
);
|
||||
}
|
||||
}
|
||||
// Unknown commands are silently ignored to avoid spamming
|
||||
}
|
||||
|
||||
async _initializeBotCommands() {
|
||||
await this._loadBotIdentity();
|
||||
|
||||
const botCommands = Object.entries(COMMANDS).map(([command, description]) => ({
|
||||
command: command.replace('/', ''),
|
||||
description: description.substring(0, 256),
|
||||
}));
|
||||
|
||||
// Register commands only for the configured chat to avoid global discovery.
|
||||
await this._setMyCommands(botCommands, this._buildConfiguredChatScope());
|
||||
}
|
||||
|
||||
async _loadBotIdentity() {
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/getMe`, {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`getMe failed (${res.status}): ${err.substring(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.ok || !data.result?.username) {
|
||||
throw new Error('getMe returned invalid bot profile');
|
||||
}
|
||||
this._botUsername = String(data.result.username).toLowerCase();
|
||||
}
|
||||
|
||||
async _setMyCommands(commands, scope = null) {
|
||||
const body = { commands };
|
||||
if (scope) body.scope = scope;
|
||||
|
||||
const res = await fetch(`${TELEGRAM_API}/bot${this.botToken}/setMyCommands`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`setMyCommands failed (${res.status}): ${err.substring(0, 200)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!data.ok) {
|
||||
throw new Error(`setMyCommands rejected: ${JSON.stringify(data).substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
_buildConfiguredChatScope() {
|
||||
const chatId = Number(this.chatId);
|
||||
if (!Number.isSafeInteger(chatId)) {
|
||||
throw new Error(`TELEGRAM_CHAT_ID must be a numeric chat id, got: ${this.chatId}`);
|
||||
}
|
||||
return { type: 'chat', chat_id: chatId };
|
||||
}
|
||||
|
||||
_normalizeCommand(rawCommand) {
|
||||
if (!rawCommand.startsWith('/')) return null;
|
||||
|
||||
const atIdx = rawCommand.indexOf('@');
|
||||
if (atIdx === -1) return rawCommand;
|
||||
|
||||
const command = rawCommand.substring(0, atIdx);
|
||||
const mentionedBot = rawCommand.substring(atIdx + 1).toLowerCase();
|
||||
if (!this._botUsername || mentionedBot === this._botUsername) return command;
|
||||
return null;
|
||||
}
|
||||
|
||||
// ─── Semantic Dedup ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
137
lib/i18n.mjs
Normal file
137
lib/i18n.mjs
Normal file
@@ -0,0 +1,137 @@
|
||||
// Internationalization (i18n) Module
|
||||
// Loads locale files and provides translation functions
|
||||
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const LOCALES_DIR = join(__dirname, '..', 'locales');
|
||||
|
||||
// Supported languages
|
||||
const SUPPORTED_LOCALES = ['en', 'fr'];
|
||||
const DEFAULT_LOCALE = 'en';
|
||||
|
||||
// Cache loaded locales
|
||||
const localeCache = new Map();
|
||||
|
||||
/**
|
||||
* Get the current language from environment
|
||||
* @returns {string} Language code (e.g., 'en', 'fr')
|
||||
*/
|
||||
export function getLanguage() {
|
||||
// CRUCIX_LANG takes priority to avoid conflict with Linux system LANGUAGE variable
|
||||
const lang = (process.env.CRUCIX_LANG || process.env.LANGUAGE || process.env.LANG || DEFAULT_LOCALE)
|
||||
.toLowerCase()
|
||||
.slice(0, 2);
|
||||
return SUPPORTED_LOCALES.includes(lang) ? lang : DEFAULT_LOCALE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a locale file
|
||||
* @param {string} lang - Language code
|
||||
* @returns {object} Locale data
|
||||
*/
|
||||
function loadLocale(lang) {
|
||||
if (localeCache.has(lang)) {
|
||||
return localeCache.get(lang);
|
||||
}
|
||||
|
||||
const localePath = join(LOCALES_DIR, `${lang}.json`);
|
||||
|
||||
if (!existsSync(localePath)) {
|
||||
console.warn(`[i18n] Locale file not found: ${localePath}, falling back to ${DEFAULT_LOCALE}`);
|
||||
return loadLocale(DEFAULT_LOCALE);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(readFileSync(localePath, 'utf-8'));
|
||||
localeCache.set(lang, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`[i18n] Failed to load locale ${lang}:`, err.message);
|
||||
if (lang !== DEFAULT_LOCALE) {
|
||||
return loadLocale(DEFAULT_LOCALE);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current locale data
|
||||
* @returns {object} Current locale data
|
||||
*/
|
||||
export function getLocale() {
|
||||
return loadLocale(getLanguage());
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a key path (e.g., 'dashboard.title')
|
||||
* @param {string} keyPath - Dot-separated key path
|
||||
* @param {object} params - Optional parameters for interpolation
|
||||
* @returns {string} Translated string or key if not found
|
||||
*/
|
||||
export function t(keyPath, params = {}) {
|
||||
const locale = getLocale();
|
||||
const keys = keyPath.split('.');
|
||||
|
||||
let value = locale;
|
||||
for (const key of keys) {
|
||||
if (value && typeof value === 'object' && key in value) {
|
||||
value = value[key];
|
||||
} else {
|
||||
console.warn(`[i18n] Missing translation: ${keyPath}`);
|
||||
return keyPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof value !== 'string') {
|
||||
return keyPath;
|
||||
}
|
||||
|
||||
// Interpolate parameters: {param} -> value
|
||||
return value.replace(/\{(\w+)\}/g, (_, key) => {
|
||||
return params[key] !== undefined ? params[key] : `{${key}}`;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LLM system prompt in current language
|
||||
* @returns {string} System prompt for LLM
|
||||
*/
|
||||
export function getLLMPrompt() {
|
||||
const locale = getLocale();
|
||||
// Use loadLocale('en') for fallback since getLocale() doesn't accept a language argument
|
||||
const fallbackLocale = loadLocale('en');
|
||||
return locale.llm?.systemPrompt || fallbackLocale.llm?.systemPrompt || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported locales info
|
||||
* @returns {Array} Array of locale info objects
|
||||
*/
|
||||
export function getSupportedLocales() {
|
||||
return SUPPORTED_LOCALES.map(code => {
|
||||
const locale = loadLocale(code);
|
||||
return {
|
||||
code,
|
||||
name: locale.meta?.name || code,
|
||||
nativeName: locale.meta?.nativeName || code
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a language is supported
|
||||
* @param {string} lang - Language code
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isSupported(lang) {
|
||||
return SUPPORTED_LOCALES.includes(lang?.toLowerCase()?.slice(0, 2));
|
||||
}
|
||||
|
||||
// Export current language on module load
|
||||
export const currentLanguage = getLanguage();
|
||||
|
||||
console.log(`[i18n] Language: ${currentLanguage}`);
|
||||
@@ -1,21 +1,23 @@
|
||||
// LLM Factory — creates the configured provider or returns null
|
||||
|
||||
import { AnthropicProvider } from './anthropic.mjs';
|
||||
import { OpenAIProvider } from './openai.mjs';
|
||||
import { OpenRouterProvider } from './openrouter.mjs';
|
||||
import { GeminiProvider } from './gemini.mjs';
|
||||
import { CodexProvider } from './codex.mjs';
|
||||
import { MiniMaxProvider } from './minimax.mjs';
|
||||
import { OllamaProvider } from './ollama.mjs';
|
||||
import { AnthropicProvider } from "./anthropic.mjs";
|
||||
import { OpenAIProvider } from "./openai.mjs";
|
||||
import { OpenRouterProvider } from "./openrouter.mjs";
|
||||
import { GeminiProvider } from "./gemini.mjs";
|
||||
import { CodexProvider } from "./codex.mjs";
|
||||
import { MiniMaxProvider } from "./minimax.mjs";
|
||||
import { MistralProvider } from "./mistral.mjs";
|
||||
import { OllamaProvider } from "./ollama.mjs";
|
||||
|
||||
export { LLMProvider } from './provider.mjs';
|
||||
export { AnthropicProvider } from './anthropic.mjs';
|
||||
export { OpenAIProvider } from './openai.mjs';
|
||||
export { OpenRouterProvider } from './openrouter.mjs';
|
||||
export { GeminiProvider } from './gemini.mjs';
|
||||
export { CodexProvider } from './codex.mjs';
|
||||
export { MiniMaxProvider } from './minimax.mjs';
|
||||
export { OllamaProvider } from './ollama.mjs';
|
||||
export { LLMProvider } from "./provider.mjs";
|
||||
export { AnthropicProvider } from "./anthropic.mjs";
|
||||
export { OpenAIProvider } from "./openai.mjs";
|
||||
export { OpenRouterProvider } from "./openrouter.mjs";
|
||||
export { GeminiProvider } from "./gemini.mjs";
|
||||
export { CodexProvider } from "./codex.mjs";
|
||||
export { MiniMaxProvider } from "./minimax.mjs";
|
||||
export { MistralProvider } from "./mistral.mjs";
|
||||
export { OllamaProvider } from "./ollama.mjs";
|
||||
|
||||
/**
|
||||
* Create an LLM provider based on config.
|
||||
@@ -28,22 +30,26 @@ export function createLLMProvider(llmConfig) {
|
||||
const { provider, apiKey, model } = llmConfig;
|
||||
|
||||
switch (provider.toLowerCase()) {
|
||||
case 'anthropic':
|
||||
case "anthropic":
|
||||
return new AnthropicProvider({ apiKey, model });
|
||||
case 'openai':
|
||||
case "openai":
|
||||
return new OpenAIProvider({ apiKey, model });
|
||||
case 'openrouter':
|
||||
case "openrouter":
|
||||
return new OpenRouterProvider({ apiKey, model });
|
||||
case 'gemini':
|
||||
case "gemini":
|
||||
return new GeminiProvider({ apiKey, model });
|
||||
case 'codex':
|
||||
case "codex":
|
||||
return new CodexProvider({ model });
|
||||
case 'minimax':
|
||||
case "minimax":
|
||||
return new MiniMaxProvider({ apiKey, model });
|
||||
case 'ollama':
|
||||
case "mistral":
|
||||
return new MistralProvider({ apiKey, model });
|
||||
case "ollama":
|
||||
return new OllamaProvider({ model, baseUrl: llmConfig.baseUrl });
|
||||
default:
|
||||
console.warn(`[LLM] Unknown provider "${provider}". LLM features disabled.`);
|
||||
console.warn(
|
||||
`[LLM] Unknown provider "${provider}". LLM features disabled.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
51
lib/llm/mistral.mjs
Normal file
51
lib/llm/mistral.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Mistral AI Provider — raw fetch, no SDK
|
||||
// Uses Mistral's OpenAI-compatible Chat Completions API
|
||||
|
||||
import { LLMProvider } from './provider.mjs';
|
||||
|
||||
export class MistralProvider extends LLMProvider {
|
||||
constructor(config) {
|
||||
super(config);
|
||||
this.name = 'mistral';
|
||||
this.apiKey = config.apiKey;
|
||||
this.model = config.model || 'mistral-large-latest';
|
||||
}
|
||||
|
||||
get isConfigured() { return !!this.apiKey; }
|
||||
|
||||
async complete(systemPrompt, userMessage, opts = {}) {
|
||||
const res = await fetch('https://api.mistral.ai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.model,
|
||||
max_tokens: opts.maxTokens || 4096,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMessage },
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(opts.timeout || 60000),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.text().catch(() => '');
|
||||
throw new Error(`Mistral API ${res.status}: ${err.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const text = data.choices?.[0]?.message?.content || '';
|
||||
|
||||
return {
|
||||
text,
|
||||
usage: {
|
||||
inputTokens: data.usage?.prompt_tokens || 0,
|
||||
outputTokens: data.usage?.completion_tokens || 0,
|
||||
},
|
||||
model: data.model || this.model,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user