#!/usr/bin/env bun
import { basename,join,resolve } from 'node:path'; import { readFileSync,realpathSync,statSync } from 'node:fs';
import { resolveChannel, type BotRow } from './resolve.js'; import { decryptField } from './crypto.js'; import { openStore, type Message } from './store.js'; import { resolveSessionIdentity, productionIdentityDeps } from './identity.js'; import { resolveSessionMark } from './mark.js'; import { renderOutbound,renderReply } from './format.js';
const BOTMASTER_DIR=process.env.BOTMASTER_DIR??join(process.env.HOME!,'Projects','Botmaster','bot-template'), DB=process.env.BOTMASTER_D1_DATABASE_NAME??'botmaster', CF=process.env.CLOUDFLARE_API_BASE??'https://api.cloudflare.com/client/v4', TG=process.env.TELEGRAM_API_BASE??'https://api.telegram.org', DEFAULT=process.env.BOTMASTER_DEFAULT_CHANNEL??'overdeck', MAX_ATTACHMENT_BYTES=45*1024*1024, SECRET_PATH_PATTERNS=[/(^|\/)\.env(?:\..*)?$/i,/\.(?:pem|key|p12|pfx)$/i,/(^|\/)id_(?:rsa|ed25519|ecdsa|dsa)[^/]*$/i,/(^|\/)\.secrets(?:\/|$)/i,/\.secret$/i,/(^|\/)\.aws\/(?:credentials|config)$/i,/(^|\/)(?:application_default_credentials\.json|service[-_]?account(?:[^/]*)\.json)$/i,/(^|\/)\.ssh\/(?:id_[^/]+|[^/]+\.(?:pem|key|p12|pfx))$/i]; class FatalError extends Error{}
export type Args={channel:string;text?:string;priority:'fyi'|'needs-answer';reply?:string;inbox:boolean;json:boolean;raw:boolean;attachments:string[]}; export type Attachment={path:string;realpath:string;bytes:number};
export function parseArgs(argv:string[]):Args {let channel=DEFAULT,priority:Args['priority']='fyi',prioritySeen=false,reply:string|undefined,inbox=false,json=false,raw=false;const text:string[]=[],attachments:string[]=[];for(let i=0;i<argv.length;i++){let a=argv[i]!;if(a==='--channel'||a==='--group'){channel=argv[++i]??'';if(!channel)throw new FatalError(`${a} requires a value`)}else if(a.startsWith('--channel=')||a.startsWith('--group=')){channel=a.slice(a.indexOf('=')+1);if(!channel)throw new FatalError(`${a.slice(0,a.indexOf('='))} requires a value`)}else if(a==='--text'){const value=argv[++i]??'';if(!value)throw new FatalError('--text requires a value');text.push(value)}else if(a.startsWith('--text=')){const value=a.slice(7);if(!value)throw new FatalError('--text requires a value');text.push(value)}else if(a==='--attachment'){const path=argv[++i]??'';if(!path)throw new FatalError('--attachment requires a path');attachments.push(path);if(attachments.length>5)throw new FatalError('--attachment accepts at most 5 files')}else if(a.startsWith('--attachment=')){const path=a.slice(13);if(!path)throw new FatalError('--attachment requires a path');attachments.push(path);if(attachments.length>5)throw new FatalError('--attachment accepts at most 5 files')}else if(a==='--raw')raw=true;else if(a==='--fyi'||a==='--needs-answer'){const p=a.slice(2) as Args['priority'];
// 'fyi' is also the default, so exclusivity must key off "a flag was already
// given", not off the current value — comparing values rejected plain --fyi.
if(prioritySeen)throw new FatalError('--fyi and --needs-answer are mutually exclusive');prioritySeen=true;priority=p}else if(a==='--reply'){reply=argv[++i];if(!reply)throw new FatalError('--reply requires a message id')}else if(a==='--inbox')inbox=true;else if(a==='--json')json=true;else if(a.startsWith('--'))throw new FatalError(`unknown option ${a}`);else text.push(a)}if(inbox&&(reply||text.length||attachments.length))throw new FatalError('--inbox takes no message');if(raw&&(reply||attachments.length))throw new FatalError('--raw cannot be combined with --reply or --attachment');if(json&&!inbox)throw new FatalError('--json requires --inbox');if(!inbox&&!text.join(' ').trim())throw new FatalError(attachments.length?'--attachment requires non-empty text':'usage: botmaster [--channel <name>] [--fyi | --needs-answer] "<text>"');return {channel,text:text.join(' ').trim()||undefined,priority,reply,inbox,json,raw,attachments}}
const under=(path:string,root:string)=>path===root||path.startsWith(`${root}/`);
export function validateAttachments(paths:string[],home=process.env.HOME??'',repo=process.cwd()):Attachment[]{return paths.map(path=>{let real:string,stat:ReturnType<typeof statSync>;try{real=realpathSync(path);stat=statSync(real)}catch{throw new FatalError(`attachment ${path} does not exist`)}if(!stat.isFile())throw new FatalError(`attachment ${path} is not a regular file`);if(!(stat.mode&0o444))throw new FatalError(`attachment ${path} is unreadable`);if(!stat.size)throw new FatalError(`attachment ${path} is empty`);if(stat.size>MAX_ATTACHMENT_BYTES)throw new FatalError(`attachment ${path} exceeds 45 MB`);if((home&&(under(real,join(home,'.ssh'))||under(real,join(home,'.aws'))||under(real,join(home,'.config','overdeck'))))||SECRET_PATH_PATTERNS.some(pattern=>pattern.test(real)))throw new FatalError(`attachment ${path} is secret-shaped`);if(home&&under(real,resolve(home))&&!under(real,resolve(repo))&&(stat.mode&0o777)===0o600)throw new FatalError(`attachment ${path} has mode 0600 outside the current repo`);return {path,realpath:real,bytes:stat.size}})}
export const shouldCoalesce=(a:Args)=>!a.raw&&!a.attachments.length&&!a.reply&&a.priority==='fyi';
export const attachmentNeedsTextMessage=(text:string)=>text.length>1024;
export const attachmentPayload=(text:string,seq:number,first:number,chatId:string|number,priority:Args['priority'])=>{const payload:any={chat_id:chatId};if(priority==='fyi')payload.disable_notification=true;if(seq===1&&text.length<=1024)payload.caption=text;if(seq>1)payload.reply_to_message_id=first;return payload};
function id(){const p=join(BOTMASTER_DIR,'wrangler.toml');const t=readFileSync(p,'utf8');for(const s of t.split(/^[ \t]*\[\[d1_databases\]\][ \t]*$/m).slice(1)){const b=s.split(/^[ \t]*\[/m)[0]!,n=b.match(/database_name\s*=\s*"([^"]+)"/)?.[1],x=b.match(/database_id\s*=\s*"([^"]+)"/)?.[1];if(n===DB&&x)return x}throw new FatalError('botmaster D1 database id is not configured')}
async function rows():Promise<BotRow[]>{const a=process.env.CLOUDFLARE_ACCOUNT_ID,t=process.env.CLOUDFLARE_API_TOKEN;if(!a||!t)throw new FatalError('Cloudflare credentials are not set');let r:Response;try{r=await fetch(`${CF}/accounts/${a}/d1/database/${id()}/query`,{method:'POST',headers:{authorization:`Bearer ${t}`,'content-type':'application/json'},body:JSON.stringify({sql:'SELECT id, project_name, bot_name, telegram_token, allowed_chat_ids FROM bots'})})}catch{throw new FatalError('botmaster D1 request failed')}if(!r.ok)throw new FatalError(`botmaster D1 query HTTP ${r.status}`);const b:any=await r.json();if(b.success!==true)throw new FatalError('botmaster D1 query rejected');return b.result?.[0]?.results??[]}
export async function telegram(token:string,method:string,body:any):Promise<number>{let r:Response;try{r=await fetch(`${TG}/bot${token}/${method}`,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)})}catch{throw new FatalError(`telegram ${method} request failed`)}if(!r.ok)throw new FatalError(`telegram ${method} failed: HTTP ${r.status}`);const x:any=await r.json();if(x.ok!==true)throw new FatalError(`telegram ${method} failed`);return Number(x.result?.message_id??0)}
export async function telegramMultipart(token:string,method:string,body:Record<string,string|number|boolean|undefined>,attachment:Attachment):Promise<number>{const form=new FormData();for(const [key,value] of Object.entries(body))if(value!==undefined)form.set(key,String(value));form.set('document',new Blob([readFileSync(attachment.realpath)]),basename(attachment.realpath));let r:Response;try{r=await fetch(`${TG}/bot${token}/${method}`,{method:'POST',body:form})}catch{throw new FatalError(`telegram ${method} request failed`)}if(!r.ok)throw new FatalError(`telegram ${method} failed: HTTP ${r.status}`);const x:any=await r.json();if(x.ok!==true)throw new FatalError(`telegram ${method} failed`);return Number(x.result?.message_id??0)}
async function main(){const a=parseArgs(process.argv.slice(2)),attachments=a.inbox?[]:validateAttachments(a.attachments),store=openStore(),identity=resolveSessionIdentity(process.pid,productionIdentityDeps);try{if(a.inbox){const x=store.undelivered(identity.sessionId);console.log(a.json?JSON.stringify(x):x.map(m=>`#${m.id}: ${m.text}`).join('\n'));return}const target=resolveChannel(await rows(),a.channel),key=process.env.DB_ENC_KEY;if(!key)throw new FatalError('DB_ENC_KEY is not set');const token=await decryptField(key,target.token);if(!token)throw new FatalError('decrypted telegram token is empty');const now=Date.now(),mark=a.raw?'':resolveSessionMark(identity.sessionId,store,now);if(shouldCoalesce(a)){const old=store.recentOutbound(identity.sessionId,now-30000);if(old&&old.tgMessageId){const text=`${old.text}\n${a.text}`;await telegram(token,'editMessageText',{chat_id:target.chatId,message_id:old.tgMessageId,text});store.updateText(old.id,text);console.log(old.id);return}}let parent:Message|null=null;if(a.reply){parent=store.getMessage(a.reply);if(!parent||parent.direction!=='in')throw new FatalError('reply parent must be an inbound message')}let m:Message|undefined;for(let n=0;n<8;n++){const mid=store.mintId();m={id:mid,direction:'out',sessionId:identity.sessionId,ticketId:null,channel:a.channel,chatId:Number(target.chatId),priority:a.priority,parentId:parent?.id??null,text:a.raw?a.text!:a.reply?renderReply(identity,mark,parent!.id,mid,a.text!):renderOutbound(identity,mark,mid,null,a.text!),createdAt:now,attempts:0,deliveredAt:null,escalatedAt:null,tgMessageId:null};try{store.insertMessage(m);break}catch(e){
// insertMessage already rethrows anything that is not an id collision; never
// relabel that as an id-exhaustion failure.
if(!/UNIQUE constraint/i.test(String((e as Error)?.message??e)))throw e;if(n===7)throw new FatalError('could not mint a unique message id')}}if(!attachments.length){const payload:any={chat_id:target.chatId,text:m!.text};if(a.priority==='fyi')payload.disable_notification=true;const tg=await telegram(token,'sendMessage',payload);if(tg)store.setTgMessageId(m!.id,tg);console.log(m!.id);return}let first=0;if(attachmentNeedsTextMessage(m!.text)){const payload:any={chat_id:target.chatId,text:m!.text};if(a.priority==='fyi')payload.disable_notification=true;const tg=await telegram(token,'sendMessage',payload);if(tg)store.setTgMessageId(m!.id,tg)}for(let n=0;n<attachments.length;n++){const file=attachments[n]!,payload=attachmentPayload(m!.text,n+1,first,target.chatId,a.priority);let tg:number;try{tg=await telegramMultipart(token,'sendDocument',payload,file)}catch(e){throw new FatalError(`attachment ${n+1} (${file.path}) failed: ${e instanceof Error?e.message:'unexpected error'}`)}if(!first)first=tg;store.recordAttachment(m!.id,n+1,file.path,file.bytes,tg);if(n===0&&!attachmentNeedsTextMessage(m!.text)&&tg)store.setTgMessageId(m!.id,tg)}console.log(m!.id)}finally{store.close()}}
if(import.meta.main)main().catch(e=>{console.error(`botmaster notify: ${e instanceof Error?e.message:'unexpected error'}`);process.exit(1)});
