LOADING
bota.chat ai wrapper unlimited, can read file, tts, transcript, web search, up-to-date
#!/usr/bin/env node
/**
* Bota.chat API Wrapper (ESM)
* Fully automatic anonymous auth → chat → stream response
* Output: clean JSON with important fields only
*
* Usage:
* node bota.js "pesan kamu"
* node bota.js "pesan" --model default-model --web-search
* node bota.js --file path/to/doc.md "jelaskan file ini"
* import { Bota } from './bota.js'
*/
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
import { basename, extname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const BASE = 'https://bota.chat';
const FRAME_ID = 'x8OA7Azs4hBy';
const SITE_ID = 53;
const UA =
'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
// ─── Helpers ───────────────────────────────────────────────
function headers(token = null, extra = {}) {
const h = {
Accept: 'application/json',
'Content-Type': 'application/json',
Origin: BASE,
Referer: `${BASE}/`,
'User-Agent': UA,
'Accept-Language': 'id-ID,id;q=0.9,en-US;q=0.8,en;q=0.7',
...extra,
};
if (token) h['x-anon-token'] = token;
return h;
}
async function req(method, path, { body, token, formData } = {}) {
const url = path.startsWith('http') ? path : `${BASE}${path}`;
const opts = { method, headers: headers(token) };
if (formData) {
// Let fetch set multipart boundary
delete opts.headers['Content-Type'];
opts.body = formData;
} else if (body) {
opts.body = JSON.stringify(body);
}
const res = await fetch(url, opts);
const ct = res.headers.get('content-type') || '';
// SSE stream
if (ct.includes('text/event-stream') || ct.includes('event-stream')) {
return { ok: res.ok, status: res.status, stream: res.body };
}
// Some proxies strip content-type; peek if body starts like SSE
// But we can only read body once — so for stream endpoint force stream if ok
if (url.includes('/frame/stream') && res.ok) {
return { ok: true, status: res.status, stream: res.body };
}
const text = await res.text();
let data = null;
try {
data = JSON.parse(text);
} catch {
data = text;
}
return { ok: res.ok, status: res.status, data };
}
// ─── SSE Parser ────────────────────────────────────────────
async function parseSSE(readableStream, onEvent) {
const reader = readableStream.getReader();
const decoder = new TextDecoder();
let buffer = '';
const events = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
for (const part of parts) {
const line = part
.split('\n')
.find((l) => l.startsWith('data: '));
if (!line) continue;
try {
const json = JSON.parse(line.slice(6));
events.push(json);
if (onEvent) onEvent(json);
} catch {
// skip malformed
}
}
}
return events;
}
// ─── Core Class ────────────────────────────────────────────
export class Bota {
constructor(opts = {}) {
this.token = opts.token || null;
this.publicId = opts.publicId || null;
this.chatId = opts.chatId || null;
this.locale = opts.locale || 'en';
this.currency = opts.currency || 'USD';
this.model = opts.model || 'default-model';
this.verbose = opts.verbose || false;
this.autoRotate = opts.autoRotate !== false; // default ON
this.maxRotate = opts.maxRotate ?? Infinity; // unlimited by default
this._rotateCount = 0;
this._sessionFile = opts.sessionFile || join(__dirname, '.bota-session.json');
this._cookies = {}; // name -> value
this._xsrf = null;
}
log(...args) {
if (this.verbose) console.error('[bota]', ...args);
}
/** Load saved session if exists */
loadSession() {
if (!existsSync(this._sessionFile)) return false;
try {
const s = JSON.parse(readFileSync(this._sessionFile, 'utf8'));
this.token = s.token;
this.publicId = s.publicId;
this.chatId = s.chatId || null;
this.log('session loaded', this.publicId || '');
return true;
} catch {
return false;
}
}
/** Persist token + chat */
saveSession() {
try {
writeFileSync(
this._sessionFile,
JSON.stringify(
{
token: this.token,
publicId: this.publicId,
chatId: this.chatId,
savedAt: new Date().toISOString(),
},
null,
2
)
);
} catch (e) {
this.log('saveSession skip:', e.message);
}
}
_cookieHeader() {
return Object.entries(this._cookies)
.map(([k, v]) => `${k}=${v}`)
.join('; ');
}
_ingestSetCookies(res) {
const list =
typeof res.headers.getSetCookie === 'function'
? res.headers.getSetCookie()
: [];
// fallback single header
const single = res.headers.get('set-cookie');
const all = list.length ? list : single ? [single] : [];
for (const c of all) {
const [nv] = c.split(';');
const eq = nv.indexOf('=');
if (eq < 0) continue;
const name = nv.slice(0, eq).trim();
const val = nv.slice(eq + 1).trim();
this._cookies[name] = val;
if (name === 'XSRF-TOKEN') {
try {
this._xsrf = decodeURIComponent(val);
} catch {
this._xsrf = val;
}
}
}
}
/**
* Bootstrap Laravel session cookies (needed for TTS / some APIs)
*/
async ensureBrowserSession() {
if (this._xsrf && this._cookies['framesuite-session']) return;
await this.ensureAuth();
if (!this.chatId) await this.ensureChat();
const url = `${BASE}/__fs/f/${FRAME_ID}?c=${this.chatId || ''}&inside=1&fs_locale=${this.locale}`;
this.log('bootstrap session cookies...');
try {
const res = await fetch(url, {
method: 'GET',
headers: {
Accept: 'text/html, application/json',
'User-Agent': UA,
Origin: BASE,
Referer: `${BASE}/`,
'x-anon-token': this.token,
},
redirect: 'manual',
});
this._ingestSetCookies(res);
// also set anon cookie from our token
if (this.token) this._cookies['anonymous_user_token'] = this.token;
this.log('cookies', Object.keys(this._cookies).join(','));
} catch (e) {
this.log('session bootstrap fail:', e.message);
}
}
/** Clear session & force new guest */
clearSession() {
this.token = null;
this.publicId = null;
this.chatId = null;
this._cookies = {};
this._xsrf = null;
try {
if (existsSync(this._sessionFile)) {
writeFileSync(this._sessionFile, '{}');
}
} catch {}
}
// ── Auth ────────────────────────────────────────────────
async ensureAuth() {
if (this.token) {
const me = await this.getMe();
if (me?.success) return me;
this.log('token invalid, rotating guest...');
this.clearSession();
}
return this.anonLogin();
}
/**
* Force new anonymous guest (rotate)
*/
async rotateGuest() {
this._rotateCount++;
// optional soft cap only if user explicitly set a finite maxRotate
if (Number.isFinite(this.maxRotate) && this._rotateCount > this.maxRotate) {
throw new Error(`Max guest rotate reached (${this.maxRotate})`);
}
this.log(`rotating guest #${this._rotateCount}...`);
this.clearSession();
// jitter + backoff biar tidak kena rate-limit server
const backoff = Math.min(8000, 300 + this._rotateCount * 200 + Math.random() * 500);
await sleep(backoff);
return this.anonLogin();
}
async anonLogin() {
this.log('anonymous login...');
// randomize fingerprint a bit each rotate
const screens = ['360x812', '390x844', '412x915', '375x812', '414x896'];
const screen = screens[Math.floor(Math.random() * screens.length)];
const langs = ['id-ID', 'en-US', 'id', 'en'];
const lang = langs[Math.floor(Math.random() * langs.length)];
const { ok, data, status } = await req(
'POST',
`/__fs/f/${FRAME_ID}/anon?fs_locale=${this.locale}`,
{
body: {
screen,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Jakarta',
navigator_language: lang,
ui_locale: this.locale,
},
}
);
if (!ok || !data?.success) {
throw new Error(`Anon login failed (${status}): ${JSON.stringify(data)}`);
}
this.token = data.anonymous_token;
this.publicId = data.public_id;
this.chatId = null; // always new chat after rotate
this.saveSession();
this.log('got token', this.token.slice(0, 8) + '...', 'id=', this.publicId);
return data;
}
async getMe() {
const { data, status } = await req(
'GET',
`/__fs/f/${FRAME_ID}/me?fs_locale=${this.locale}`,
{ token: this.token }
);
if (status === 401 || data?.success === false) return { success: false };
return data;
}
// ── Chat ────────────────────────────────────────────────
async createChat() {
this.log('creating chat...');
const { ok, data, status } = await req(
'POST',
`/__fs/f/${FRAME_ID}/chat?fs_locale=${this.locale}`,
{
token: this.token,
body: { site_id: SITE_ID, agent_open: false },
}
);
if (!ok || !data?.success) {
// auth/credit problem → rotate
if (this.autoRotate && (status === 401 || status === 402 || status === 429 || status === 403)) {
await this.rotateGuest();
return this.createChat();
}
throw new Error(`Create chat failed (${status}): ${JSON.stringify(data)}`);
}
this.chatId = data.chat.hash_id;
this.saveSession();
this.log('chat_id =', this.chatId);
return data.chat;
}
async ensureChat() {
if (this.chatId) return { hash_id: this.chatId };
return this.createChat();
}
async listChats() {
const { data } = await req('GET', `/__fs/f/${FRAME_ID}/chats?fs_locale=${this.locale}`, {
token: this.token,
});
return data;
}
/** Detect paywall / limit errors from stream or JSON body */
_isLimitError(errMsg = '', data = null) {
const s = (errMsg + ' ' + JSON.stringify(data || {})).toLowerCase();
return (
s.includes('credit') ||
s.includes('paywall') ||
s.includes('limit') ||
s.includes('quota') ||
s.includes('balance') ||
s.includes('unauthorized') ||
s.includes('401') ||
s.includes('402') ||
s.includes('403') ||
s.includes('429') ||
s.includes('forbidden') ||
s.includes('rate')
);
}
// ── Upload ──────────────────────────────────────────────
async upload(filePath) {
if (!existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
await this.ensureAuth();
const buf = readFileSync(filePath);
const name = basename(filePath);
const form = new FormData();
const blob = new Blob([buf], { type: mimeFromExt(extname(name)) });
form.append('files[]', blob, name);
this.log('uploading', name);
const { ok, data, status } = await req(
'POST',
`/__fs/f/${FRAME_ID}/upload?fs_locale=${this.locale}`,
{ token: this.token, formData: form }
);
if (!ok || !data?.success) {
if (this.autoRotate && this._isLimitError('', data) || status === 401) {
await this.rotateGuest();
return this.upload(filePath);
}
throw new Error(`Upload failed (${status}): ${JSON.stringify(data)}`);
}
// Normalize to format expected by stream endpoint
return (data.files || []).map((f) => ({
id: f.id,
name: f.name,
type: 'file',
mime: f.mime,
size: f.size,
url: f.url,
path: f.path,
file: {},
uploading: false,
}));
}
// ── Stream / Chat ───────────────────────────────────────
/**
* Send message and collect full response
* Auto-rotates guest on credit/auth failure
*/
async chat(message, opts = {}) {
this._rotateCount = 0; // reset per top-level call
return this._chatOnce(message, opts);
}
async _chatOnce(message, opts = {}) {
await this.ensureAuth();
await this.ensureChat();
// Attachments from previous guest won't work after rotate — re-upload if needed
let attachments = opts.attachments || [];
const payload = {
chat_id: this.chatId,
locale: this.locale,
paywall_currency: this.currency,
message,
model: opts.model || this.model,
image_mode: !!opts.imageMode,
video_mode: !!opts.videoMode,
diagram_mode: !!opts.diagramMode,
plot_mode: !!opts.plotMode,
web_search: !!opts.webSearch,
voice_mode: !!opts.voiceMode,
hidden: false,
attachments,
vision_capable: opts.visionCapable !== false,
tools_capable: opts.toolsCapable !== false,
image_gen_capable: !!opts.imageGenCapable,
from_suggestion: false,
agent_open: false,
};
this.log('streaming...', message.slice(0, 60));
const res = await req(
'POST',
`/__fs/api/frame/stream?fs_locale=${this.locale}`,
{ token: this.token, body: payload }
);
// Hard HTTP failure → maybe rotate
if (!res.ok) {
const errStr = JSON.stringify(res.data);
if (this.autoRotate && this._isLimitError(errStr, res.data)) {
this.log('limit/auth error, rotating...');
await this.rotateGuest();
// drop attachments (belong to old guest); caller can re-pass file path via opts._filePath
if (opts._filePath) {
attachments = await this.upload(opts._filePath);
opts = { ...opts, attachments };
} else {
opts = { ...opts, attachments: [] };
}
return this._chatOnce(message, opts);
}
throw new Error(`Stream failed (HTTP ${res.status}): ${errStr}`);
}
if (!res.stream) {
const errStr = JSON.stringify(res.data || {}).slice(0, 400);
if (this.autoRotate && this._isLimitError(errStr, res.data)) {
this.log('paywall in body, rotating...');
await this.rotateGuest();
if (opts._filePath) {
opts = { ...opts, attachments: await this.upload(opts._filePath) };
} else {
opts = { ...opts, attachments: [] };
}
return this._chatOnce(message, opts);
}
throw new Error(`Stream failed (HTTP ${res.status}, no SSE): ${errStr}`);
}
const stream = res.stream;
const result = {
success: true,
chat_id: this.chatId,
public_id: this.publicId,
message: '',
message_id: null,
sources: [],
tools: [],
reasoning: null,
suggestions: [],
timing: null,
billing: null,
model: null,
credits: null,
new_balance: null,
rotated: this._rotateCount > 0,
rotate_count: this._rotateCount,
};
const chunks = [];
let currentTool = null;
let streamError = null;
await parseSSE(stream, (ev) => {
// server-side error events
if (ev.error || ev.paywall || ev.limit_reached) {
streamError = ev;
return;
}
if (ev.agent_thinking) {
this.log('thinking...');
}
if (ev.tool_step) {
const t = ev.tool_step;
if (t.status === 'running' && t.args) {
currentTool = {
id: t.id,
name: t.name,
args: t.args,
status: 'running',
};
this.log(`tool: ${t.name}`, t.args?.query || '');
}
if (t.status === 'done') {
const tool = {
id: t.id,
name: t.name,
args: currentTool?.args || t.args,
status: 'done',
duration_ms: t.duration_ms,
sources_count: t.sources?.length || 0,
};
result.tools.push(tool);
if (t.sources) {
for (const s of t.sources) {
result.sources.push({
url: s.url,
title: s.title,
snippet: (s.content || '').slice(0, 300),
});
}
}
currentTool = null;
}
}
if (ev.agent_reasoning) {
result.reasoning = ev.agent_reasoning;
}
if (ev.chunk) {
chunks.push(ev.chunk);
if (this.verbose) process.stderr.write(ev.chunk);
}
if (ev.done) {
result.message_id = ev.message_id ?? null;
result.suggestions = ev.suggestions || [];
result.timing = ev.timing || null;
result.billing = ev.billing || null;
result.model = ev.timing?.model || null;
result.credits = ev.timing?.credits ?? null;
result.new_balance = ev.new_balance ?? null;
if (ev.sources?.length) {
result.sources = ev.sources.map((s) => ({
url: s.url,
title: s.title,
snippet: (s.content || s.snippet || '').slice(0, 300),
}));
}
if (this.verbose) process.stderr.write('\n');
}
});
// If stream itself signalled paywall/limit
if (streamError && this.autoRotate && this._isLimitError('', streamError)) {
this.log('stream paywall event, rotating...');
await this.rotateGuest();
if (opts._filePath) {
opts = { ...opts, attachments: await this.upload(opts._filePath) };
} else {
opts = { ...opts, attachments: [] };
}
return this._chatOnce(message, opts);
}
result.message = chunks.join('');
// Empty response after "success" can also mean limit — rotate once
if (
!result.message &&
!result.message_id &&
this.autoRotate
) {
this.log('empty response, trying rotate...');
await this.rotateGuest();
if (opts._filePath) {
opts = { ...opts, attachments: await this.upload(opts._filePath) };
} else {
opts = { ...opts, attachments: [] };
}
return this._chatOnce(message, opts);
}
// dedupe sources by url
const seen = new Set();
result.sources = result.sources.filter((s) => {
if (!s.url || seen.has(s.url)) return false;
seen.add(s.url);
return true;
});
return result;
}
// ── TTS / Voice ──────────────────────────────────────────
/**
* Text → speech (WAV buffer)
* @param {string} text
* @param {{ messageId?: number|null }} [opts]
* @returns {Promise<Buffer>}
*/
async tts(text, opts = {}) {
await this.ensureAuth();
if (!this.chatId) await this.ensureChat();
await this.ensureBrowserSession();
const body = {
text: String(text || '').replace(/\*\*/g, '').slice(0, 5000),
chat_id: this.chatId,
};
if (opts.messageId != null) body.message_id = opts.messageId;
this.log('tts...', body.text.slice(0, 50));
const url = `${BASE}/__fs/api/frame/tts?fs_locale=${this.locale}`;
const hdrs = {
Accept: '*/*',
'Content-Type': 'application/json',
Origin: BASE,
Referer: `${BASE}/?c=${this.chatId}&inside=1`,
'User-Agent': UA,
'x-anon-token': this.token,
};
if (this._xsrf) {
hdrs['X-XSRF-TOKEN'] = this._xsrf;
hdrs['X-CSRF-TOKEN'] = this._xsrf;
}
const cookie = this._cookieHeader();
if (cookie) hdrs['Cookie'] = cookie;
const res = await fetch(url, {
method: 'POST',
headers: hdrs,
body: JSON.stringify(body),
});
this._ingestSetCookies(res);
if (!res.ok) {
const t = await res.text().catch(() => '');
// refresh session once then retry
if (res.status === 401 || res.status === 419) {
this._cookies = {};
this._xsrf = null;
await this.ensureBrowserSession();
const res2 = await fetch(url, {
method: 'POST',
headers: {
...hdrs,
'X-XSRF-TOKEN': this._xsrf || '',
'X-CSRF-TOKEN': this._xsrf || '',
Cookie: this._cookieHeader(),
'x-anon-token': this.token,
},
body: JSON.stringify(body),
});
if (res2.ok) {
const buf2 = Buffer.from(await res2.arrayBuffer());
this.log('tts ok', buf2.length, 'bytes');
return buf2;
}
const t2 = await res2.text().catch(() => '');
throw new Error(`TTS failed (HTTP ${res2.status}): ${t2.slice(0, 200)}`);
}
throw new Error(`TTS failed (HTTP ${res.status}): ${t.slice(0, 200)}`);
}
const buf = Buffer.from(await res.arrayBuffer());
if (buf.length < 44) {
throw new Error('TTS returned empty/invalid audio');
}
this.log('tts ok', buf.length, 'bytes');
return buf;
}
/**
* Speech (WAV/base64 or file path)
* @param {string|Buffer} audio path ke .wav ATAU Buffer
* @returns {Promise<string>}
*/
async transcribe(audio) {
await this.ensureAuth();
let buf;
if (typeof audio === 'string') {
if (!existsSync(audio)) throw new Error(`Audio file not found: ${audio}`);
buf = readFileSync(audio);
} else if (Buffer.isBuffer(audio)) {
buf = audio;
} else {
throw new Error('transcribe() butuh path string atau Buffer');
}
const b64 = buf.toString('base64');
this.log('transcribe...', buf.length, 'bytes');
await this.ensureBrowserSession();
const url = `${BASE}/__fs/api/frame/transcribe?fs_locale=${this.locale}`;
const hdrs = {
Accept: '*/*',
'Content-Type': 'application/json',
Origin: BASE,
Referer: `${BASE}/?c=${this.chatId || ''}&inside=1`,
'User-Agent': UA,
'x-anon-token': this.token,
};
if (this._xsrf) {
hdrs['X-XSRF-TOKEN'] = this._xsrf;
hdrs['X-CSRF-TOKEN'] = this._xsrf;
}
const cookie = this._cookieHeader();
if (cookie) hdrs['Cookie'] = cookie;
const raw = await fetch(url, {
method: 'POST',
headers: hdrs,
body: JSON.stringify({ audio: b64 }),
});
this._ingestSetCookies(raw);
const ct = raw.headers.get('content-type') || '';
let res;
if (ct.includes('event-stream') || url.includes('transcribe')) {
// always try SSE parse if body is stream-like
if (ct.includes('event-stream')) {
res = { ok: raw.ok, status: raw.status, stream: raw.body };
} else {
const textBody = await raw.text();
// SSE-looking?
if (textBody.includes('data: ')) {
// fake parse
const chunks = [];
for (const line of textBody.split('\n')) {
if (line.startsWith('data: ')) {
try {
const j = JSON.parse(line.slice(6));
if (j.chunk) chunks.push(j.chunk);
if (j.text) chunks.push(j.text);
} catch {}
}
}
res = { ok: raw.ok, status: raw.status, data: { text: chunks.join('') } };
} else {
try {
res = { ok: raw.ok, status: raw.status, data: JSON.parse(textBody) };
} catch {
res = { ok: raw.ok, status: raw.status, data: textBody };
}
}
}
} else {
const textBody = await raw.text();
try {
res = { ok: raw.ok, status: raw.status, data: JSON.parse(textBody) };
} catch {
res = { ok: raw.ok, status: raw.status, data: textBody };
}
}
// bisa SSE atau JSON
let text = '';
if (res.stream) {
const chunks = [];
await parseSSE(res.stream, (ev) => {
if (ev.chunk) chunks.push(ev.chunk);
if (ev.text) chunks.push(ev.text);
if (ev.transcript) chunks.push(ev.transcript);
});
text = chunks.join('');
} else if (res.data) {
if (typeof res.data === 'string') text = res.data;
else text = res.data.text || res.data.transcript || res.data.message || '';
// kadang data adalah gabungan event di string
if (!text && typeof res.data === 'object') {
text = JSON.stringify(res.data);
}
}
text = (text || '').trim();
this.log('transcribed:', text.slice(0, 80));
if (!text) throw new Error('Transcribe kosong / gagal');
return text;
}
/**
* Kirim voice file → dapat balasan teks (+ opsional TTS wav)
* @param {string} audioPath path .wav
* @param {object} opts sama seperti chat(), plus tts: true|path
*/
async chatVoice(audioPath, opts = {}) {
const transcript = await this.transcribe(audioPath);
const result = await this.chat(transcript, {
...opts,
voiceMode: true,
});
result.transcript = transcript;
if (opts.tts) {
const outPath =
typeof opts.tts === 'string'
? opts.tts
: `bota-reply-${result.message_id || Date.now()}.wav`;
try {
const wav = await this.tts(result.message, { messageId: result.message_id });
writeFileSync(outPath, wav);
result.tts_file = outPath;
result.tts_bytes = wav.length;
this.log('saved tts →', outPath);
} catch (e) {
result.tts_error = e.message;
this.log('tts error:', e.message);
}
}
return result;
}
/**
* Chat teks + simpan balasan jadi wav
*/
async chatWithTts(message, opts = {}) {
const result = await this.chat(message, opts);
const outPath =
typeof opts.tts === 'string'
? opts.tts
: opts.ttsFile || `bota-reply-${result.message_id || Date.now()}.wav`;
try {
const wav = await this.tts(result.message, { messageId: result.message_id });
writeFileSync(outPath, wav);
result.tts_file = outPath;
result.tts_bytes = wav.length;
} catch (e) {
result.tts_error = e.message;
}
return result;
}
/**
* One-shot: force fresh guest every time
*/
async ask(message, opts = {}) {
this.clearSession();
this._rotateCount = 0;
return this.chat(message, opts);
}
}
function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}
// ─── Utils ─────────────────────────────────────────────────
function mimeFromExt(ext) {
const map = {
'.md': 'text/markdown',
'.txt': 'text/plain',
'.pdf': 'application/pdf',
'.json': 'application/json',
'.html': 'text/html',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
};
return map[ext.toLowerCase()] || 'application/octet-stream';
}
// ─── CLI ───────────────────────────────────────────────────
function parseArgs(argv) {
const args = {
message: '',
model: 'default-model',
webSearch: false,
voiceMode: false,
imageMode: false,
file: null,
voiceIn: null, // path audio input (.wav)
tts: false, // true | path output wav
newChat: false,
verbose: false,
session: true,
help: false,
};
const positional = [];
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--help' || a === '-h') args.help = true;
else if (a === '--web-search' || a === '-w') args.webSearch = true;
else if (a === '--voice') args.voiceMode = true;
else if (a === '--image') args.imageMode = true;
else if (a === '--new' || a === '-n') args.newChat = true;
else if (a === '--verbose' || a === '-v') args.verbose = true;
else if (a === '--no-session') args.session = false;
else if (a === '--tts') {
// --tts atau --tts out.wav
const next = argv[i + 1];
if (next && !next.startsWith('-')) {
args.tts = argv[++i];
} else {
args.tts = true;
}
}
else if (a === '--voice-in' || a === '--mic' || a === '--audio') args.voiceIn = argv[++i];
else if (a === '--model' || a === '-m') args.model = argv[++i];
else if (a === '--file' || a === '-f') args.file = argv[++i];
else if (!a.startsWith('-')) positional.push(a);
}
args.message = positional.join(' ').trim();
return args;
}
function printHelp() {
console.log(`
Bota.chat CLI Wrapper
Usage:
node bota.js "pesan kamu di sini"
node bota.js "siapa presiden RI?" --web-search
node bota.js --file README.md "ringkas dokumen ini"
node bota.js "halo" --tts # balasan + simpan wav
node bota.js "halo" --tts jawab.wav
node bota.js --voice-in rekaman.wav --tts # kirim voice, dapat voice
Options:
-m, --model <name> Model (default: default-model)
-w, --web-search Aktifkan web search
-f, --file <path> Upload file lalu tanya
--voice-in <wav> Kirim file audio (transcribe → chat)
--tts [path.wav] Balasan AI di-TTS ke file wav
-n, --new Buat chat baru (ignore session)
-v, --verbose Log progress ke stderr
--no-session Jangan simpan/load session
-h, --help Bantuan
Output: JSON ke stdout (hanya field penting)
`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help || (!args.message && !args.file && !args.voiceIn)) {
printHelp();
process.exit(args.help ? 0 : 1);
}
const client = new Bota({
verbose: args.verbose,
model: args.model,
});
if (args.session && !args.newChat) {
client.loadSession();
}
if (args.newChat) {
client.chatId = null;
}
try {
await client.ensureAuth();
let attachments = [];
if (args.file) {
attachments = await client.upload(args.file);
if (!args.message) {
args.message = `Tolong analisis / jelaskan file: ${basename(args.file)}`;
}
}
await client.ensureChat();
let result;
if (args.voiceIn) {
// kirim voice → transcribe → chat → optional tts
result = await client.chatVoice(args.voiceIn, {
model: args.model,
webSearch: args.webSearch,
imageMode: args.imageMode,
attachments,
tts: args.tts,
});
} else if (args.tts) {
result = await client.chatWithTts(args.message, {
model: args.model,
webSearch: args.webSearch,
voiceMode: args.voiceMode,
imageMode: args.imageMode,
attachments,
_filePath: args.file || null,
tts: args.tts,
});
} else {
result = await client.chat(args.message, {
model: args.model,
webSearch: args.webSearch,
voiceMode: args.voiceMode,
imageMode: args.imageMode,
attachments,
_filePath: args.file || null,
});
}
// Output clean JSON only
const out = {
success: result.success,
chat_id: result.chat_id,
public_id: result.public_id,
message_id: result.message_id,
model: result.model,
message: result.message,
transcript: result.transcript || undefined,
tts_file: result.tts_file || undefined,
tts_bytes: result.tts_bytes || undefined,
tts_error: result.tts_error || undefined,
sources: result.sources,
tools: (result.tools || []).map((t) => ({
name: t.name,
query: t.args?.query || null,
duration_ms: t.duration_ms,
})),
rotated: result.rotated || false,
rotate_count: result.rotate_count || 0,
timing: result.timing
? {
total_ms: result.timing.total,
credits: result.timing.credits,
tools: result.timing.tools,
}
: null,
billing: result.billing
? {
prompt_tokens: result.billing.prompt_tokens,
completion_tokens: result.billing.completion_tokens,
total_tokens: result.billing.total_tokens,
cost_usd: result.billing.cost_usd,
}
: null,
new_balance: result.new_balance,
suggestions: result.suggestions,
};
console.log(JSON.stringify(out, null, 2));
} catch (err) {
console.log(
JSON.stringify(
{
success: false,
error: err.message,
},
null,
2
)
);
process.exit(1);
}
}
// Run CLI if executed directly
const isMain =
process.argv[1] &&
fileURLToPath(import.meta.url) === process.argv[1];
if (isMain) {
main();
}Ngobrol sama AI bota.chat dari terminal. Login anon otomatis, chat, file, search, suara.
--web-search)--image).wav (--tts).wav, di-transcribe terus dijawab (--voice-in).bota-session.jsonNode 18+, gak perlu npm install.
bash# chat biasa node bota.js "halo siapa kamu" # jawaban + suara (wav) node bota.js "ceritain singkat tentang kucing" --tts node bota.js "halo" --tts jawaban.wav # kirim rekaman suara kamu, dapat teks + suara balasan node bota.js --voice-in rekaman.wav --tts balasan.wav # file dokumen node bota.js --file notes.md "ringkas ini" # web search node bota.js "presiden Indonesia sekarang" -w # verbose / chat baru node bota.js -v -n "mulai bersih"
Guest gratis tetep ada limit credits. Kalau kejedot, script bikin guest baru sendiri.
Habis rotate, obrolan lama lupa — wajar.
JSON ke stdout. Kalau pakai TTS / voice-in, ada field ekstra:
json{ "success": true, "message": "Halo dari Bota!", "tts_file": "jawaban.wav", "tts_bytes": 76844, "transcript": "...", "credits": 7, "rotated": false }
tts_file itu path wav yang bisa langsung diputar.
jsimport { Bota } from './bota.js'; const bot = new Bota({ verbose: true }); // teks + wav const a = await bot.chatWithTts('halo', { tts: 'out.wav' }); // voice in → voice out const b = await bot.chatVoice('rekaman.wav', { tts: 'balasan.wav' }); // TTS manual dari teks const wav = await bot.tts('halo dunia');
intinya: teks, file, search, kirim suara, terima suara — udah bisa dari CLI.