'use strict'; const crypto=require('crypto'); const START=/^\/start(?:@bflcontentbot)?\s*$/i; const id=v=>String(v??''); function hash(x){return crypto.createHash('sha256').update(typeof x==='string'?x:JSON.stringify(x)).digest('hex');} function safeEqual(a,b){const x=Buffer.from(String(a)),y=Buffer.from(String(b));return x.length===y.length&&crypto.timingSafeEqual(x,y);} function requireThat(test,message){if(!test)throw Object.assign(new Error(message),{code:message});} function identity(update){ const q=update?.callback_query,m=q?.message||update?.message,from=q?.from||m?.from; if(!from||from.is_bot!==false||!Number.isSafeInteger(from.id)||from.id<=0||!m||m.chat?.type!=='private'||m.chat.id!==from.id||!Number.isSafeInteger(update.update_id))return null; if(q&&(!q.id||!Number.isSafeInteger(m.message_id)||m.message_id<=0))return null; return {user_id:id(from.id),chat_id:id(m.chat.id),username:String(from.username||''),event_key:q?'cb:'+q.id:'u:'+update.update_id,update_id:update.update_id,callback_id:q?id(q.id):'',message_id:q?id(m.message_id):'',data:q?String(q.data||''):'',text:q?'':String(m.text||'').trim(),reply_id:!q&&m.reply_to_message?.message_id?id(m.reply_to_message.message_id):''}; } class Collaboration { constructor({db,owner,channel,secret,transport,clock=()=>Date.now(),tables={}}){this.db=db;this.owner=id(owner);this.channel=id(channel);this.secret=secret;this.transport=transport;this.clock=clock;this.tables={versions:'data_table_user_KszG5ycOvocFLwHW',batches:'data_table_user_BFLNewsBatchV04',items:'data_table_user_BFLNewsItemV04',...tables};for(const v of Object.values(this.tables))requireThat(/^[A-Za-z0-9_]+$/.test(v),'invalid_table_config');} async q(s,p=[]){return (await this.db.query(s,p)).rows;} async member(user){return (await this.q('SELECT * FROM bfl_collab_members WHERE telegram_user_id=$1 AND active=true',[user]))[0]||null;} async audit(actor,action,ctx,result,details={}){await this.q('INSERT INTO bfl_collab_audit(actor_user_id,action,draft_key,version,batch_key,item_key,event_key,result,details) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9)',[actor?.user_id||null,action,ctx.draft_key||null,ctx.version||null,ctx.batch_key||null,ctx.item_key||null,actor?.event_key||ctx.event_key||'',result,JSON.stringify(details)]);} ticket(actor){const data=Buffer.from(JSON.stringify({...actor,expires:this.clock()+3600000})).toString('base64url');return data+'.'+crypto.createHmac('sha256',this.secret).update(data).digest('base64url');} decode(ticket){const [data,sig,extra]=String(ticket||'').split('.');requireThat(data&&sig&&!extra&&safeEqual(sig,crypto.createHmac('sha256',this.secret).update(data).digest('base64url')),'invalid_actor_ticket');const a=JSON.parse(Buffer.from(data,'base64url').toString());requireThat(a.expires>this.clock(),'expired_actor_ticket');return a;} async access(ticket){const a=this.decode(ticket);const m=await this.member(a.user_id);requireThat(m&&id(m.private_chat_id)===a.chat_id&&['owner','editor'].includes(m.role),'access_revoked');return {...a,role:m.role};} async version(draft,v){return (await this.q('SELECT * FROM "'+this.tables.versions+'" WHERE draft_key=$1 AND version=$2 AND owner_chat_id=$3',[draft,v,this.owner]))[0]||null;} async latest(draft){return Number((await this.q('SELECT max(version) AS v FROM "'+this.tables.versions+'" WHERE draft_key=$1 AND owner_chat_id=$2',[draft,this.owner]))[0]?.v||0);} async copy(actor,kind,ctx){ const purpose=kind==='card'?['card']:['preview','preview_text_legacy']; const rows=await this.q("SELECT * FROM bfl_collab_deliveries WHERE recipient_user_id=$1 AND chat_id=$2 AND message_id=$3 AND purpose=ANY($4::text[]) AND state='sent'",[actor.user_id,actor.chat_id,actor.message_id,purpose]); const found=rows.find(r=>kind==='card'?r.batch_key===ctx.batch_key&&r.item_key===ctx.item_key:r.draft_key===ctx.draft_key&&Number(r.version)===Number(ctx.version)); if(found)return {valid:true,delivery_id:id(found.id),legacy:false}; if(actor.user_id!==this.owner)return {valid:false}; // Old owner copies remain usable, but no fallback is allowed for tracked multiuser deliveries. if(kind==='card'){ const r=(await this.q('SELECT * FROM "'+this.tables.items+'" WHERE batch_key=$1 AND item_key=$2 AND owner_chat_id=$3',[ctx.batch_key,ctx.item_key,this.owner]))[0]; const tracked=await this.q("SELECT id FROM bfl_collab_deliveries WHERE purpose='card' AND batch_key=$1 AND item_key=$2 LIMIT 1",[ctx.batch_key,ctx.item_key]); return {valid:!tracked.length&&!!r&&id(r.message_id)===actor.message_id,legacy:true}; } const r=await this.version(ctx.draft_key,ctx.version); return {valid:!!r&&r.delivery_tracking!=='multiuser_v1'&&id(r.approval_message_id)===actor.message_id,legacy:true}; } async event(update,executionId=''){ const a=identity(update);if(!a)return {allowed:false,update,access:{allowed:false}}; const m=await this.member(a.user_id);let startText='';const isStart=START.test(a.text)&&!a.callback_id; if(isStart){await this.q('INSERT INTO bfl_collab_registrations(telegram_user_id,private_chat_id,username,update_id,execution_id) VALUES($1,$2,$3,$4,$5) ON CONFLICT(telegram_user_id) DO UPDATE SET username=EXCLUDED.username,update_id=EXCLUDED.update_id,execution_id=EXCLUDED.execution_id,seen_at=now()',[a.user_id,a.chat_id,a.username,a.update_id,String(executionId)]);startText=m?'Ваша роль: '+(m.role==='owner'?'владелец':'редактор')+'. Команды: /post тема, /news, /retry ID версия, /resume ID версия. Общие черновики можно дорабатывать, отклонять и публиковать кнопками.':'Доступ пока не выдан. Ваш Telegram ID: '+a.user_id+'. Передайте его владельцу проекта.';} if(!m){await this.audit(a,isStart?'start':'access_denied',{},isStart?'pending':'denied');return {allowed:false,update,access:{allowed:false,user_id:a.user_id,chat_id:a.chat_id,start_text:startText}};} const context={...a,role:m.role};let copy={valid:false};const news=/^news:([A-Za-z0-9_-]{1,16}):([A-Za-z0-9_-]{1,12})$/.exec(a.data),draft=/^(p|r|x)\|([A-Za-z0-9_-]{1,40})\|([1-9]\d{0,8})$/.exec(a.data); if(news){Object.assign(context,{batch_key:news[1],item_key:news[2],action:'select'});copy=await this.copy(a,'card',context);} if(draft){Object.assign(context,{draft_key:draft[2],version:Number(draft[3]),action:{p:'publish',r:'revise',x:'reject'}[draft[1]]});copy=await this.copy(a,'preview',context);} let feedback=null;if(a.reply_id){feedback=(await this.q('SELECT draft_key,version FROM "'+this.tables.versions+'" WHERE status=\'awaiting_feedback\' AND owner_chat_id=$1 AND feedback_prompt_message_id=$2 AND coalesce(nullif(feedback_actor_user_id,\'\'),$1)=$3 AND coalesce(nullif(feedback_chat_id,\'\'),$1)=$4 ORDER BY version DESC LIMIT 1',[this.owner,a.reply_id,a.user_id,a.chat_id]))[0]||null;} const access={...context,allowed:true,ticket:this.ticket(context),copy_valid:copy.valid,copy_legacy:copy.legacy||false,start_text:startText,feedback}; await this.audit(a,context.action||(/^\/news(?:\s|@|$)/i.test(a.text)?'news':/^\/post(?:\s|@|$)/i.test(a.text)?'post':isStart?'start':a.reply_id?'feedback':'command'),context,'received',{copy_valid:copy.valid,execution_id:String(executionId)}); return {allowed:true,update,access}; } async internal(context,executionId=''){if(context.recovery_execution_id)return this.recoverPost(context,executionId);const m=await this.member(this.owner);requireThat(m?.role==='owner','owner_disabled');const a={user_id:this.owner,chat_id:this.owner,role:'owner',event_key:'internal:'+hash(context).slice(0,24),action:'operator_internal'};await this.audit(a,'internal_context',context,'registered',{execution_id:String(executionId)});return {allowed:true,update:context,access:{...a,allowed:true,ticket:this.ticket(a)}};} async authorize({ticket,context={},binary_meta={},stage='',execution_id=''}){const a=await this.access(ticket);requireThat(!context.operation_actor_user_id||id(context.operation_actor_user_id)===a.user_id,'actor_context_mismatch');await this.audit(a,'effect_gate',context,'allowed',{stage,execution_id:String(execution_id)});return {context:{...context,_bfl:{ticket,user_id:a.user_id,chat_id:a.chat_id,role:a.role,event_key:a.event_key}},binary_meta};} async executionContext({ticket,context={},execution_id=''}){ const a=await this.access(ticket);requireThat(/^\d+$/.test(String(execution_id)),'invalid_execution_id'); await this.audit(a,'execution_context',context,'registered',{execution_id:String(execution_id)});return context; } async recoverPost(context,executionId){ requireThat(/^\d+$/.test(String(context.recovery_execution_id||'')),'invalid_recovery_reference'); const proof=(await this.q("SELECT * FROM bfl_collab_audit WHERE action='operator_recovery_proof' AND details->>'source_execution_id'=$1 ORDER BY id DESC LIMIT 1",[String(context.recovery_execution_id)]))[0]; requireThat(proof&&proof.result==='ready'&&proof.details.no_external_request===true,'recovery_proof_not_ready'); const original=identity(proof.details.update);requireThat(original&&original.event_key===proof.event_key&&original.user_id===id(proof.actor_user_id),'recovery_identity_mismatch'); const m=await this.member(original.user_id);requireThat(m&&id(m.private_chat_id)===original.chat_id,'access_revoked'); const row=await this.version(proof.draft_key,proof.version); requireThat(row&&row.status==='text_preparing'&&row.source_event_key===original.event_key&&row.version===1&&row.operation_actor_user_id===original.user_id&&!row.text&&!row.source_image_id&&!row.image_response_id&&!row.image_attempts&&!row.approval_message_id&&!row.published_message_id&&row.error_code==='local_expression_error_before_request','recovery_state_changed'); requireThat((await this.latest(row.draft_key))===1,'recovery_version_changed'); const claimed=await this.q("UPDATE bfl_collab_audit SET result='claimed',details=details||$2::jsonb WHERE id=$1 AND result='ready' RETURNING id",[proof.id,JSON.stringify({claimed_execution_id:String(executionId),claimed_at:new Date().toISOString()})]);requireThat(claimed.length===1,'recovery_already_claimed'); const a={...original,role:m.role,recovery_proof_id:id(proof.id)}; await this.audit(a,'recovery_start',row,'claimed',{execution_id:String(executionId),source_execution_id:String(context.recovery_execution_id)}); return {allowed:true,update:{mode:'confirmed_post_resume',row},access:{...a,allowed:true,ticket:this.ticket(a)}}; } async executionFailure({execution_id,workflow_id,last_node='',error_name=''}){ const execution=String(execution_id||'');requireThat(/^\d+$/.test(execution),'invalid_execution_id'); requireThat(['tKcwMnRnIJuaKYte','BFLNewsCard04','BFLNewsGround04','BFLNewsReader04','BFLNewsSearch04','BFLNewsSelected041','BFLNewsStatus04','BFLNewsVerify04','BFLStatusV031001','BFLEditorPostResume339'].includes(workflow_id),'workflow_not_allowed'); const context=(await this.q("SELECT * FROM bfl_collab_audit WHERE details->>'execution_id'=$1 AND actor_user_id IS NOT NULL ORDER BY id DESC LIMIT 1",[execution]))[0]; const initiating=context?await this.member(id(context.actor_user_id)):null; const recipient=initiating?{user_id:id(initiating.telegram_user_id),chat_id:id(initiating.private_chat_id)}:{user_id:this.owner,chat_id:this.owner}; const a={...recipient,role:initiating?.role||'owner',event_key:context?.event_key||'error:'+execution,action:'technical_failure'}; const safeName=/^[A-Za-z][A-Za-z0-9_]{0,79}$/.test(error_name)?error_name:'ExecutionError'; await this.audit(a,'execution_failed',context||{},'stopped',{execution_id:execution,workflow_id,node:String(last_node).slice(0,160),error_name:safeName}); const text='Обработка остановлена из-за технической ошибки. Сохранённый материал не удалён. Автоматический повтор не запущен, чтобы не повторить запрос или отправку. Сообщи владельцу код выполнения '+execution+'.'; const ticket=this.ticket(a),rows=[]; rows.push(await this.deliver(a,recipient,'technical_failure',execution,context||{},'sendMessage',{text},ticket)); if(recipient.user_id!==this.owner)rows.push(await this.deliver(a,{user_id:this.owner,chat_id:this.owner},'technical_failure',execution,context||{},'sendMessage',{text:'Технический отказ при обработке запроса редактора. Код выполнения '+execution+'. Материал сохранён; автоматический повтор остановлен.'},ticket)); return {notified:rows.map(r=>({recipient_user_id:id(r.recipient_user_id),state:r.state})),execution_id:execution}; } async reserve(a,recipient,purpose,key,ctx,body){ const canonical={...body};delete canonical._asset;if(purpose.startsWith('preview'))canonical.photo=ctx.asset_id||ctx.cover_asset_path||ctx.telegram_file_id;const h=hash(canonical);const inserted=await this.q("INSERT INTO bfl_collab_deliveries(purpose,entity_key,recipient_user_id,chat_id,draft_key,version,batch_key,item_key,operation_id,actor_user_id,event_key,request_hash,state) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'sending') ON CONFLICT(purpose,entity_key,recipient_user_id) DO NOTHING RETURNING *",[purpose,key,recipient.user_id,recipient.chat_id,ctx.draft_key||null,ctx.version||null,ctx.batch_key||null,ctx.item_key||null,ctx.operation_id||null,a.user_id,a.event_key,h]); if(inserted.length)return {send:true,row:inserted[0]}; const row=(await this.q('SELECT * FROM bfl_collab_deliveries WHERE purpose=$1 AND entity_key=$2 AND recipient_user_id=$3',[purpose,key,recipient.user_id]))[0];if(purpose==='status'&&row.state==='rejected'&&row.result?.error_code===429&&Number(row.attempts)<2&&this.clock()>=new Date(row.updated_at).getTime()+Math.max(Number(row.result.parameters?.retry_after||1),1)*1000){const retry=(await this.q("UPDATE bfl_collab_deliveries SET state='sending',attempts=attempts+1,request_hash=$2,updated_at=now() WHERE id=$1 AND state='rejected' AND attempts<2 RETURNING *",[row.id,h]))[0];if(retry)return {send:true,row:retry};} requireThat(row.request_hash===h||purpose==='status','delivery_payload_conflict');return {send:false,row}; } async deliver(a,recipient,purpose,key,ctx,method,body,ticket){ const reservation=await this.reserve(a,recipient,purpose,key,ctx,body);if(!reservation.send)return reservation.row; const row=reservation.row;let result,state,error=''; try{ await this.access(ticket);if(recipient.user_id!=='0')requireThat(await this.member(recipient.user_id),'recipient_revoked'); result=await this.transport(method,{...body,chat_id:recipient.chat_id}); if(result?.ok===true&&result.result&&id(result.result.chat?.id)===id(recipient.chat_id)&&Number.isSafeInteger(result.result.message_id)&&result.result.message_id>0){state='sent';} else if(result?.not_sent===true){state='not_sent';error='asset_unavailable_before_send';} else if(result?.ok===false&&Number(result.error_code)>=400&&Number(result.error_code)<500){state='rejected';error='telegram_rejected_'+result.error_code;} else{state='unknown';error='telegram_result_unconfirmed';} }catch(e){state=/revoked/.test(e.code||e.message)?'revoked':'unknown';error=state==='revoked'?e.code||e.message:'telegram_transport_interrupted';result=null;} const saved=(await this.q('UPDATE bfl_collab_deliveries SET state=$2,result=$3,message_id=$4,error_code=$5,updated_at=now() WHERE id=$1 RETURNING *',[row.id,state,result?JSON.stringify(result):null,state==='sent'?result.result.message_id:null,error]))[0]; await this.audit(a,purpose,ctx,state,{delivery_id:id(row.id),recipient_user_id:recipient.user_id,error});return saved; } async recipients(kind){return (await this.q('SELECT * FROM bfl_collab_members WHERE active=true AND '+(kind==='card'?'receive_batches':'receive_previews')+'=true ORDER BY CASE WHEN role=\'owner\' THEN 0 ELSE 1 END,telegram_user_id')).map(m=>({user_id:id(m.telegram_user_id),chat_id:id(m.private_chat_id)}));} aggregate(rows){const sent=rows.filter(r=>r.state==='sent'&&r.result?.ok);if(!sent.length&&rows.length===1&&rows[0].state==='rejected'&&rows[0].result)return rows[0].result;if(!sent.length)return {ok:false,error_code:rows.some(r=>['unknown','sending'].includes(r.state))?504:400,description:'No confirmed delivery',delivery:{verified:false,states:rows.map(r=>({recipient_user_id:id(r.recipient_user_id),state:r.state}))}};return {...sent[0].result,primary_chat_id:id(sent[0].chat_id),delivery:{verified:true,states:rows.map(r=>({recipient_user_id:id(r.recipient_user_id),state:r.state,message_id:r.message_id?id(r.message_id):''}))}};} async telegram({ticket,purpose,context:ctx={},method,body={}}){ const a=await this.access(ticket);requireThat(['callback','help','feedback','progress','preview','card','channel','preview_photo_legacy','preview_text_legacy','channel_photo_legacy','channel_text_legacy'].includes(purpose),'unsupported_delivery_purpose'); if(purpose==='callback'){requireThat(method==='answerCallbackQuery'&&id(body.callback_query_id)===a.callback_id,'callback_actor_mismatch');return this.transport(method,{callback_query_id:a.callback_id,text:String(body.text||'').slice(0,200),show_alert:!!body.show_alert});} if(purpose==='help'){requireThat(method==='sendMessage','invalid_help_method');return this.aggregate([await this.deliver(a,{user_id:a.user_id,chat_id:a.chat_id},'help',a.event_key,ctx,method,{text:String(body.text||''),...(body.reply_markup?{reply_markup:body.reply_markup}:{})},ticket)]);} if(purpose==='progress')return this.progress(a,ctx,body,ticket); if(purpose==='card'){ requireThat(method==='sendMessage','invalid_card_method');const item=(await this.q('SELECT * FROM "'+this.tables.items+'" WHERE batch_key=$1 AND item_key=$2 AND owner_chat_id=$3',[ctx.batch_key,ctx.item_key,this.owner]))[0];requireThat(item&&['sending','offered'].includes(item.status),'card_not_deliverable');requireThat(body.reply_markup?.inline_keyboard?.flat()?.[0]?.callback_data==='news:'+ctx.batch_key+':'+ctx.item_key,'card_callback_mismatch');const rows=[];for(const recipient of await this.recipients('card'))rows.push(await this.deliver(a,recipient,'card',ctx.batch_key+':'+ctx.item_key,ctx,method,body,ticket));return this.aggregate(rows); } const r=await this.version(ctx.draft_key,ctx.version);requireThat(r&&Number(r.version)===await this.latest(r.draft_key),'version_not_latest'); if(purpose==='feedback'){requireThat(method==='sendMessage'&&r.status==='awaiting_feedback'&&id(r.feedback_actor_user_id)===a.user_id&&id(r.feedback_chat_id)===a.chat_id,'feedback_session_mismatch');return this.aggregate([await this.deliver(a,{user_id:a.user_id,chat_id:a.chat_id},purpose,r.draft_key+':'+r.version,r,method,body,ticket)]);} if(purpose==='preview_photo_legacy')body={...body,photo:r.telegram_file_id||undefined,_asset:r.cover_asset_path}; if(purpose.startsWith('preview')){ requireThat(['sending_preview_photo','sending_preview_text','awaiting_approval'].includes(r.status),'preview_state_invalid'); if(purpose==='preview'){requireThat(method==='sendPhoto'&&body.caption===r.caption&&r.caption===r.text&&r.caption===r.channel_caption&&r.caption.length<=1024,'preview_content_mismatch');requireThat(r.origin!=='news'||r.news_validation==='verified','news_not_verified');requireThat(body.reply_markup?.inline_keyboard?.flat()?.map(b=>b.callback_data).join(',')===['p','r','x'].map(v=>v+'|'+r.draft_key+'|'+r.version).join(','),'preview_buttons_mismatch');body={...body,photo:r.telegram_file_id||undefined,_asset:r.cover_asset_path};} const rows=[];let photo=body.photo;for(const recipient of await this.recipients('preview')){const sendBody={...body,...(photo?{photo,_asset:undefined}:{})};const row=await this.deliver(a,recipient,purpose,r.draft_key+':'+r.version,r,method,sendBody,ticket);rows.push(row);if(row.state==='sent'&&row.result?.result?.photo?.length)photo=row.result.result.photo.at(-1).file_id;} return this.aggregate(rows); } // Channel destination and material come from fixed configuration and the current DB row, never from the request recipient. requireThat(a.action==='publish'&&a.draft_key===r.draft_key&&Number(a.version)===Number(r.version),'publication_actor_event_mismatch');requireThat((await this.copy(a,'preview',r)).valid,'publication_copy_invalid');requireThat(r.status==='publishing'&&r.publication_event_key===a.event_key&&!r.published_message_id,'publication_not_claimed');requireThat(r.origin!=='news'||r.news_validation==='verified','news_not_verified'); if(purpose==='channel'){requireThat(method==='sendPhoto'&&r.format==='photo_caption_v1'&&body.caption===r.caption&&body.photo===r.telegram_file_id&&r.caption===r.text&&r.caption===r.channel_caption&&r.caption.length<=1024,'publication_content_mismatch');body={photo:r.telegram_file_id,caption:r.caption};} else if(purpose==='channel_photo_legacy'){requireThat(method==='sendPhoto'&&body.photo===r.telegram_file_id,'legacy_photo_mismatch');} else requireThat(method==='sendMessage'&&body.text===String(r.channel_text).replace(/&/g,'&').replace(//g,'>')&&r.channel_photo_state==='sent','legacy_text_mismatch'); return this.aggregate([await this.deliver(a,{user_id:'0',chat_id:this.channel},purpose,r.draft_key+':'+r.version,r,method,body,ticket)]); } async progress(a,ctx,body,ticket){ requireThat(typeof body.text==='string'&&body.text.length>0,'status_text_missing');const key=String(ctx.operation_id||ctx.batch_key||ctx.draft_key+':'+ctx.version)+':'+a.user_id; const prior=(await this.q("SELECT * FROM bfl_collab_deliveries WHERE purpose='status' AND entity_key=$1 AND recipient_user_id=$2",[key,a.user_id]))[0]; if(!prior)return this.aggregate([await this.deliver(a,{user_id:a.user_id,chat_id:a.chat_id},'status',key,ctx,'sendMessage',{text:body.text,parse_mode:body.parse_mode||'HTML',disable_web_page_preview:true},ticket)]); if(prior.state==='rejected'&&prior.result?.error_code===429)return this.aggregate([await this.deliver(a,{user_id:a.user_id,chat_id:a.chat_id},'status',key,ctx,'sendMessage',{text:body.text,parse_mode:body.parse_mode||'HTML',disable_web_page_preview:true},ticket)]); if(prior.state!=='sent')return {ok:false,error_code:504,description:'Status delivery outcome is not confirmed'}; if(prior.result?.result?.text===body.text)return prior.result; await this.access(ticket);let response;try{response=await this.transport('editMessageText',{chat_id:a.chat_id,message_id:prior.message_id,text:body.text,parse_mode:body.parse_mode||'HTML'});}catch{return {ok:false,error_code:504,description:'Status edit outcome unknown'};} if(response.ok===true)await this.q('UPDATE bfl_collab_deliveries SET result=$2,updated_at=now() WHERE id=$1',[prior.id,JSON.stringify(response)]); return response; } async onboarding({ticket}){ const a=await this.access(ticket);requireThat(a.user_id===this.owner&&a.action==='operator_internal','owner_only'); const r=(await this.q('SELECT * FROM "'+this.tables.versions+'" WHERE owner_chat_id=$1 AND telegram_file_id IS NOT NULL AND telegram_file_id<>\'\' AND format=\'photo_caption_v1\' AND preview_photo_state=\'sent\' ORDER BY id DESC LIMIT 1',[this.owner]))[0];requireThat(r,'saved_test_image_missing'); const output=[];for(const recipient of await this.recipients('preview')){ const role=recipient.user_id===this.owner?'владелец':'редактор'; const confirm=await this.deliver(a,recipient,'onboarding','multiuser_v1',{},'sendMessage',{text:'БФЛ Контент: доступ подключён. Ваша роль: '+role+'. Команды: /post тема, /news, /retry ID версия, /resume ID версия. Новые карточки и превью получают оба участника. Публикация — только после кнопки «Опубликовать» под рабочим черновиком.'},ticket); const preview=await this.deliver(a,recipient,'onboarding_preview','multiuser_v1',{},'sendPhoto',{photo:r.telegram_file_id,caption:'ТЕСТ ПОДКЛЮЧЕНИЯ ДВУХ РЕДАКТОРОВ.\nСохранённая обложка проекта; новая генерация не запускалась. Это проверка доставки, кнопок публикации нет.\n\n'+String(r.caption||r.text).slice(0,800)},ticket); output.push({recipient_user_id:recipient.user_id,confirmation:confirm.state,preview:preview.state,preview_message_id:id(preview.message_id)}); }return {test:true,channel_send:false,deliveries:output}; } async decision({ticket,context:ctx={}}){ const a=await this.access(ticket),r=await this.version(ctx.draft_key,ctx.version);requireThat(r&&['published','rejected','superseded','awaiting_feedback'].includes(r.status),'decision_not_committed'); if(r.status==='published')requireThat(r.published_message_id&&r.channel_photo_state==='sent','publication_not_confirmed'); const label={published:'опубликована',rejected:'отклонена',superseded:'заменена новой версией',awaiting_feedback:'дорабатывается редактором '+a.user_id}[r.status];const text='Версия v'+r.version+' материала '+r.draft_key+' '+label+'.'; const rows=[];for(const recipient of await this.recipients('preview')){ if(r.status==='awaiting_feedback'&&recipient.user_id===a.user_id)continue; rows.push(await this.deliver(a,recipient,'decision',r.draft_key+':'+r.version+':'+r.status,r,'sendMessage',{text},ticket)); } let copies=await this.q("SELECT * FROM bfl_collab_deliveries WHERE draft_key=$1 AND version=$2 AND purpose IN ('preview','preview_text_legacy') AND state='sent' AND buttons_removed=false",[r.draft_key,r.version]); if(r.delivery_tracking!=='multiuser_v1'&&r.approval_message_id)copies.push({id:null,chat_id:this.owner,message_id:r.approval_message_id,recipient_user_id:this.owner}); for(const copy of copies){if(!await this.member(copy.recipient_user_id))continue;try{await this.access(ticket);const result=await this.transport('editMessageReplyMarkup',{chat_id:id(copy.chat_id),message_id:Number(copy.message_id),reply_markup:{inline_keyboard:[]}});if(result.ok&©.id)await this.q('UPDATE bfl_collab_deliveries SET buttons_removed=true,updated_at=now() WHERE id=$1',[copy.id]);}catch{/* A notification failure never rolls back state or repeats a publication. */}} await this.audit(a,r.status,r,'committed');return {context:ctx,decision:r.status,deliveries:rows.map(x=>({recipient_user_id:id(x.recipient_user_id),state:x.state}))}; } } module.exports={Collaboration,identity,hash,safeEqual,requireThat};