{
  "id": "BFLNewsStatus04",
  "name": "БФЛ — статус поиска новостей",
  "active": true,
  "versionId": "e9b54b87-eb85-49a5-90b1-bc99af2f4269",
  "nodes": [
    {
      "id": "fb0005c3-eeb8-4688-b1e8-5929dde8e5a6",
      "name": "Вход статуса",
      "type": "n8n-nodes-base.executeWorkflowTrigger",
      "typeVersion": 1.1,
      "parameters": {
        "inputSource": "passthrough"
      },
      "position": [
        0,
        0
      ]
    },
    {
      "id": "6f639618-6590-41b4-b97a-16216566d59d",
      "name": "Прочитать состояние операции",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "BFLNewsBatchV04",
          "cachedResultName": "bfl_content_versions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "id",
              "condition": "eq",
              "keyValue": "={{ $json.id }}"
            },
            {
              "keyName": "owner_chat_id",
              "condition": "eq",
              "keyValue": "710000001"
            },
            {
              "keyName": "operation_id",
              "condition": "eq",
              "keyValue": "={{ $json.operation_id }}"
            }
          ]
        },
        "returnAll": true
      },
      "position": [
        250,
        0
      ]
    },
    {
      "id": "640344f7-784d-46ce-81ca-f3e76f7445a3",
      "name": "Выбрать актуальный статус",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "function bflAccess(){return $('Вход статуса').first().json._bfl;}\nconst __result = await (async()=>{\n// Small, deliberately restrictive URL adapter for the n8n sandbox (global URL is absent).\n// Only the official ASCII hosts used by this workflow are representable.\nclass StrictURL {\n constructor(value){\n  if(typeof value!=='string'||value.length>1500||/[\\s\\\\\\u0000-\\u001f\\u007f]/u.test(value))throw Error('url_invalid');\n  const m=/^https:\\/\\/((?:www\\.)?(?:nalog\\.gov\\.ru|cbr\\.ru|vsrf\\.ru|pravo\\.ru|probankrotstvo\\.ru|kommersant\\.ru|rbc\\.ru))(\\/[^?#]*)?(\\?[^#]*)?(?:#[^\\s]*)?$/i.exec(value);\n  if(!m)throw Error('url_forbidden');\n  this.protocol='https:';this.hostname=m[1].toLowerCase();this.pathname=m[2]||'/';this.username='';this.password='';this.port='';this.hash='';\n  if(/%|\\/{2}|(?:^|\\/)\\.{1,2}(?:\\/|$)/.test(this.pathname))throw Error('url_path_encoding_forbidden');\n  this.params=[];\n  for(const entry of (m[3]||'').slice(1).split('&').filter(Boolean)){\n   const at=entry.indexOf('=');const key=decodeURIComponent((at<0?entry:entry.slice(0,at)).replace(/\\+/g,' '));const val=decodeURIComponent((at<0?'':entry.slice(at+1)).replace(/\\+/g,' '));\n   if(/[\\u0000-\\u001f\\u007f]/.test(key+val)||this.params.some(x=>x[0]===key))throw Error('url_query_invalid');this.params.push([key,val]);\n  }\n  this.searchParams={keys:()=>this.params.map(x=>x[0]),get:key=>this.params.find(x=>x[0]===key)?.[1]??null,delete:key=>{this.params=this.params.filter(x=>x[0]!==key)},sort:()=>this.params.sort((a,b)=>a[0].localeCompare(b[0]))};\n }\n toString(){return 'https://'+this.hostname+this.pathname+(this.params.length?'?'+this.params.map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(v)).join('&'):'');}\n}\nfunction resolveOfficialLocation(location,base){\n if(typeof location!=='string'||!location||location.startsWith('//'))throw Error('redirect_forbidden');\n const current=new StrictURL(base);let target;\n if(location.startsWith('https://'))target=location;\n else if(location.startsWith('/'))target='https://'+current.hostname+location;\n else if(location.startsWith('?'))target='https://'+current.hostname+current.pathname+location;\n else throw Error('redirect_relative_path_unsupported');\n const next=new StrictURL(target);if(next.hostname.replace(/^www\\./,'')!==current.hostname.replace(/^www\\./,''))throw Error('cross_source_redirect');return next.toString();\n}\n\nconst QUALITY=(()=>{const URL=StrictURL;\n'use strict';\n// Standalone helpers. These do not fetch pages, classify their meaning, mutate state or publish.\n// Merge into actual deployed nodes. Do not replace the server's modified v0.4 wholesale.\nconst POLICY={\"version\": \"0.4.1\", \"default_days\": 7, \"allowed_days\": [7, 30], \"max_candidates\": 20, \"max_topics\": 5, \"max_per_publisher_first_pass\": 2, \"require_exact_quotes_to_offer_topic\": false, \"verify_selected_topic_before_image\": true, \"sources\": [{\"host\": \"nalog.gov.ru\", \"name\": \"ФНС России\", \"kind\": \"official\"}, {\"host\": \"cbr.ru\", \"name\": \"Банк России\", \"kind\": \"official\"}, {\"host\": \"vsrf.ru\", \"name\": \"Верховный Суд РФ\", \"kind\": \"official\"}, {\"host\": \"pravo.ru\", \"name\": \"Право.ru\", \"kind\": \"editorial\"}, {\"host\": \"probankrotstvo.ru\", \"name\": \"PROбанкротство\", \"kind\": \"editorial\"}, {\"host\": \"kommersant.ru\", \"name\": \"Коммерсантъ\", \"kind\": \"editorial\"}, {\"host\": \"rbc.ru\", \"name\": \"РБК\", \"kind\": \"editorial\"}], \"categories\": {\"bankruptcy\": \"Банкротство граждан\", \"credit\": \"Кредиты и МФО\", \"collection\": \"Приставы и взыскание\", \"property\": \"Жильё и имущество\", \"family\": \"Семья, наследство и долги\", \"fraud\": \"Финансовое мошенничество\", \"consumer\": \"Права потребителей финансовых услуг\"}};\nfunction error(code){throw Object.assign(new Error(code),{code});}\nfunction safeText(value,max=20000){\n  if(typeof value!=='string'||!value.trim()||value.length>max)error('text_invalid');\n  if(/[\\u0000-\\u0008\\u000b\\u000c\\u000e-\\u001f\\u007f\\u202a-\\u202e\\u2066-\\u2069]/u.test(value))error('text_controls');\n  return value.normalize('NFC').replace(/\\r\\n?/g,'\\n').trim();\n}\nfunction normalizeExcerpt(value){\n  return safeText(value,60000).replace(/[\\u00ad\\u200b\\ufeff]/gu,'')\n    .replace(/[«»“”„‟]/gu,'\"').replace(/[‘’‚‛]/gu,\"'\")\n    .replace(/[\\u2010-\\u2015\\u2212]/gu,'-').replace(/\\s+/gu,' ').trim();\n}\nfunction checkExcerpt(quote,article){\n  try{\n    const q=normalizeExcerpt(quote),body=normalizeExcerpt(article);\n    // A short factual phrase may be legitimate. No arbitrary 15-character cutoff.\n    if((q.match(/[\\p{L}\\p{N}]+/gu)||[]).length<2)return {matched:false,reason:'fragment_too_short'};\n    const at=body.indexOf(q);\n    return at<0 ? {matched:false,reason:'text_differs'} : {matched:true,reason:'normalized_match',normalized_excerpt:q};\n  }catch(e){return {matched:false,reason:e.code||'evidence_invalid'};}\n}\nfunction partitionEvidence(facts,article){\n  if(!Array.isArray(facts))error('facts_invalid');\n  const matched=[],needs_review=[];\n  facts.forEach((fact,index)=>{\n    const result=checkExcerpt(fact?.evidence,article);\n    const row={index,fact,result};\n    (result.matched?matched:needs_review).push(row);\n  });\n  // Matching is provenance, NOT semantic proof. Caller must review the selected news.\n  return {matched,needs_review,topic_must_not_be_dropped_for_this_alone:true,semantic_review_required:true};\n}\nfunction sourceBlocks(article,documentKey){\n  if(typeof documentKey!=='string'||!/^[A-Za-z0-9_-]{1,80}$/.test(documentKey))error('document_key_invalid');\n  const paragraphs=safeText(article,60000).split(/\\n\\s*\\n/u).filter(p=>p.trim());\n  const chunks=[];\n  for(let paragraph of paragraphs){\n    while(paragraph.length>1800){\n      let cut=paragraph.lastIndexOf(' ',1800);\n      if(cut<900)cut=1800;\n      chunks.push(paragraph.slice(0,cut));paragraph=paragraph.slice(cut).trimStart();\n    }\n    if(paragraph.trim())chunks.push(paragraph.trim());\n  }\n  return {document_key:documentKey,blocks:chunks.map((text,i)=>({id:'p'+String(i+1).padStart(3,'0'),text}))};\n}\nfunction resolveEvidenceRefs(fact,document){\n  if(!fact||fact.document_key!==document.document_key)error('wrong_source_document');\n  if(!Array.isArray(fact.source_ids)||!fact.source_ids.length||fact.source_ids.length>3||new Set(fact.source_ids).size!==fact.source_ids.length)error('source_ids_invalid');\n  const map=new Map(document.blocks.map(b=>[b.id,b.text]));\n  return fact.source_ids.map(id=>{\n    if(!map.has(id))error('source_block_not_found');\n    return {id,text:map.get(id)};\n  });\n}\nfunction sourceURL(raw){\n  if(typeof raw!=='string'||raw.length>1500||/[\\s\\\\\\u0000-\\u001f\\u007f]/u.test(raw))error('url_invalid');\n  let u;try{u=new URL(raw);}catch{error('url_invalid');}\n  if(u.protocol!=='https:'||u.username||u.password||u.port)error('url_forbidden');\n  const source=POLICY.sources.find(s=>u.hostname===s.host||u.hostname==='www.'+s.host);\n  if(!source)error('source_not_allowed');\n  u.hostname=source.host;u.hash='';\n  for(const key of [...u.searchParams.keys()])if(/^(utm_|yclid$|gclid$|fbclid$)/i.test(key))u.searchParams.delete(key);\n  u.searchParams.sort();\n  if(source.host==='pravo.ru')u.pathname=u.pathname.replace(/(\\/(?:news|story)\\/\\d+)\\/amp\\/?$/,'$1/');const p=u.pathname;\n  const article = source.host==='nalog.gov.ru' ? /^\\/rn\\d+\\/(?:news\\/[^/]+|ifns\\/imns\\d+_\\d+\\/info)\\/\\d+\\/?$/.test(p)\n    : source.host==='cbr.ru' ? /^\\/press\\/(?:event|regevent)\\/\\/?$/i.test(p)&&/^\\d+$/.test(u.searchParams.get('id')||'') || /^\\/press\\/pr\\/\\/?$/i.test(p)&&!!u.searchParams.get('file')\n    : source.host==='vsrf.ru' ? /^(?:\\/news|\\/press_center\\/news)\\/\\d+\\/?$/.test(p)\n    : source.host==='pravo.ru' ? /^\\/(?:news|story)\\/\\d+\\/?$/.test(p)\n    : source.host==='probankrotstvo.ru' ? /^\\/(?:news|article|articles)\\/[a-z0-9-]+-\\d+\\/?$/.test(p)\n    : source.host==='kommersant.ru' ? /^\\/doc\\/\\d+\\/?$/.test(p)\n    : source.host==='rbc.ru' ? /^\\/[a-z0-9_-]+\\/\\d{2}\\/\\d{2}\\/\\d{4}\\/[a-f0-9]{24}\\/?$/i.test(p)\n    : false;\n  if(!article)error('article_path_needs_adapter');\n  if(source.host==='vsrf.ru')u.pathname='/news/'+p.match(/\\d+/)[0];\n  else if(source.host==='nalog.gov.ru'||source.host==='pravo.ru')u.pathname=p.replace(/\\/?$/,'/');\n  else if(source.host==='cbr.ru')u.pathname=p.toLowerCase().replace(/\\/?$/,'/');\n  else u.pathname=p.replace(/\\/$/,'');\n  const keep=source.host==='cbr.ru'?(/event/i.test(p)?'id':'file'):null;\n  for(const key of [...u.searchParams.keys()])if(key!==keep)u.searchParams.delete(key);\n  return {url:u.toString(),publisher:source.host,source_name:source.name,source_kind:source.kind};\n}\nfunction dateValue(value){\n  if(typeof value!=='string'||!/^\\d{4}-\\d{2}-\\d{2}$/.test(value))error('invalid_date');\n  const d=new Date(value+'T00:00:00Z');\n  if(!Number.isFinite(d.getTime())||d.toISOString().slice(0,10)!==value)error('invalid_date');\n  return value;\n}\n// read is trusted HTTP/extractor output; judgment is untrusted classifier output.\n// Actual reading, redirects, trace provenance and publication-date extraction stay in n8n.\nfunction offerTopic(read,judgment,window){\n  if(!read||!judgment||judgment.read_id!==read.read_id)error('read_id_mismatch');\n  const src=sourceURL(read.url);\n  if(read.transport!=='http'||read.status_code!==200||read.date_kind!=='published'||read.discovery_bound!==true)error('source_read_required');\n  safeText(read.article_text,60000);\n  const day=dateValue(read.published_date),from=dateValue(window.from),to=dateValue(window.to);\n  if(from>to)error('date_window_invalid');\n  if(day<from||day>to)return {offer:false,reason:'outside_window'};\n  if(judgment.relevance==='unrelated')return {offer:false,reason:'unrelated'};\n  if(!['direct','adjacent','context'].includes(judgment.relevance))error('relevance_invalid');\n  if(!Object.hasOwn(POLICY.categories,judgment.category))error('category_invalid');\n  if(!['news','analysis','opinion','explanation','statistics','advertisement'].includes(judgment.content_type))error('content_type_invalid');\n  if(judgment.content_type==='advertisement')return {offer:false,reason:'advertisement'};\n  return {offer:true,read_id:read.read_id,...src,published_date:day,\n    title:safeText(judgment.title,160),angle:safeText(judgment.angle,350),\n    relevance:judgment.relevance,category:judgment.category,category_label:POLICY.categories[judgment.category],\n    content_type:judgment.content_type,verification_state:'on_selection',\n    score:judgment.relevance==='direct'?30:judgment.relevance==='adjacent'?20:10};\n}\nfunction rankTopics(topics,{excludedURLs=[],limit=POLICY.max_topics}={}){\n  if(!Number.isInteger(limit)||limit<1||limit>POLICY.max_topics)error('limit_invalid');\n  const seen=new Set(excludedURLs.map(url=>sourceURL(url).url)),unique=[];\n  for(const topic of [...topics].filter(t=>t.offer===true).sort((a,b)=>b.score-a.score||b.published_date.localeCompare(a.published_date))){\n    const url=sourceURL(topic.url).url;\n    if(seen.has(url))continue;seen.add(url);unique.push(topic);\n  }\n  const selected=[],deferred=[],counts=new Map();\n  for(const t of unique){\n    if((counts.get(t.publisher)||0)>=POLICY.max_per_publisher_first_pass){deferred.push(t);continue;}\n    selected.push(t);counts.set(t.publisher,(counts.get(t.publisher)||0)+1);\n  }\n  // Diversity is a preference. Do not discard useful topics when only one source has them.\n  return [...selected,...deferred].slice(0,limit);\n}\nfunction topicText(topic){\n  return safeText(`${topic.title}\\n\\nИдея поста: ${topic.angle}\\n\\n${topic.category_label} · ${topic.published_date}\\n${topic.source_name}\\n${topic.url}\\n\\nПосле выбора проверю факты и подготовлю черновик.`,4096);\n}\nreturn {POLICY,normalizeExcerpt,checkExcerpt,partitionEvidence,sourceBlocks,resolveEvidenceRefs,\n  sourceURL,offerTopic,rankTopics,topicText};\n\n})();\nconst NEWS=(()=>{const URL=StrictURL;\n'use strict';\n// Pure helpers. No network, database, paid calls or Telegram mutations.\n// Port to n8n Code nodes against the actual runtime; require is not assumed there.\nconst CONFIG={\"version\": \"0.4.1\", \"timezone\": \"Europe/Moscow\", \"default_days\": 7, \"allowed_days\": [7, 30], \"max_candidates\": 20, \"max_topics\": 5, \"selection_ttl_hours\": 24, \"caption_limit_utf16\": 1024, \"sources\": [{\"host\": \"nalog.gov.ru\", \"name\": \"ФНС России\", \"kind\": \"official\", \"enabled\": true}, {\"host\": \"cbr.ru\", \"name\": \"Банк России\", \"kind\": \"official\", \"enabled\": true}, {\"host\": \"vsrf.ru\", \"name\": \"Верховный Суд РФ\", \"kind\": \"official\", \"enabled\": true}, {\"host\": \"pravo.ru\", \"name\": \"Право.ru\", \"kind\": \"editorial\", \"enabled\": true}, {\"host\": \"probankrotstvo.ru\", \"name\": \"PROбанкротство\", \"kind\": \"editorial\", \"enabled\": true}, {\"host\": \"kommersant.ru\", \"name\": \"Коммерсантъ\", \"kind\": \"editorial\", \"enabled\": true}, {\"host\": \"rbc.ru\", \"name\": \"РБК\", \"kind\": \"editorial\", \"enabled\": true}]};\nconst STAGES = Object.freeze({\n  proposal:'Инициатива', bill:'Законопроект', adopted:'Принято, вступление в силу не подтверждено',\n  in_force:'Действует', court:'Судебная практика', explanation:'Разъяснение', statistics:'Статистика'\n});\nconst CONTROLS = /[\\u0000-\\u0008\\u000b-\\u001f\\u007f\\u202a-\\u202e\\u2066-\\u2069]/u;\nfunction fail(code) { throw Object.assign(new Error(code), {code}); }\nfunction clean(value, max, field='text') {\n  if (typeof value !== 'string') fail(field+'_type');\n  const s=value.normalize('NFC').replace(/\\r\\n?/g,'\\n').trim();\n  if (!s || s.length>max || CONTROLS.test(s)) fail(field+'_invalid');\n  return s;\n}\nfunction instant(value) {\n  const t=value instanceof Date ? value.getTime() : Date.parse(value);\n  if (!Number.isFinite(t)) fail('invalid_clock');\n  return t;\n}\nfunction isoDay(value) {\n  if (typeof value!=='string' || !/^\\d{4}-\\d{2}-\\d{2}$/.test(value)) fail('invalid_date');\n  const d=new Date(value+'T00:00:00Z');\n  if (!Number.isFinite(d.getTime()) || d.toISOString().slice(0,10)!==value) fail('invalid_date');\n  return value;\n}\nfunction parseDateEvidence(value) {\n  const s=clean(value,150,'publication_date_raw').toLowerCase();\n  let m=s.match(/^(\\d{4})-(\\d{2})-(\\d{2})(?:$|t\\d{2}:)/);\n  if (m) return isoDay(`${m[1]}-${m[2]}-${m[3]}`);\n  m=s.match(/^(\\d{1,2})\\.(\\d{1,2})\\.(\\d{4})(?:$|\\s)/);\n  if (m) return isoDay(`${m[3]}-${m[2].padStart(2,'0')}-${m[1].padStart(2,'0')}`);\n  const months=['января','февраля','марта','апреля','мая','июня','июля','августа','сентября','октября','ноября','декабря'];\n  m=s.match(/^(\\d{1,2})\\s+([а-я]+)\\s+(\\d{4})(?:$|\\s)/);\n  if (m && months.includes(m[2])) return isoDay(`${m[3]}-${String(months.indexOf(m[2])+1).padStart(2,'0')}-${m[1].padStart(2,'0')}`);\n  fail('publication_date_unreadable');\n}\nfunction windowFor(days=7, now=new Date()) {\n  if (!CONFIG.allowed_days.includes(days)) fail('days_not_supported');\n  const parts=new Intl.DateTimeFormat('en-CA',{timeZone:CONFIG.timezone,year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date(instant(now)));\n  const get=t=>parts.find(p=>p.type===t).value;\n  const to=`${get('year')}-${get('month')}-${get('day')}`;\n  const from=new Date(Date.parse(to+'T00:00:00Z')-(days-1)*86400000).toISOString().slice(0,10);\n  return {from,to,days,timezone:CONFIG.timezone};\n}\nfunction parseCommand(text, botUsername='bflcontentbot') {\n  if (typeof text!=='string') return null;\n  const match=text.trim().match(/^\\/news(?:@([a-z0-9_]+))?(?:\\s+([\\s\\S]*))?$/i);\n  if (!match) return null;\n  if (match[1] && match[1].toLowerCase()!==botUsername.toLowerCase()) return null;\n  let tail=(match[2]||'').trim(),days=CONFIG.default_days;\n  const d=tail.match(/^(\\d+)(?:\\s+|$)/);\n  if(d){ days=Number(d[1]); if(!CONFIG.allowed_days.includes(days))fail('days_not_supported'); tail=tail.slice(d[0].length).trim(); }\n  return {days,topic:tail ? clean(tail,200,'topic') : ''};\n}\nfunction canonicalURL(value) {\n  if (typeof value!=='string' || value.length>1500 || /[\\s\\\\\\u0000-\\u001f\\u007f]/u.test(value)) fail('url_invalid');\n  let u; try{ u=new URL(value); }catch{ fail('url_invalid'); }\n  if(u.protocol!=='https:' || u.username || u.password || u.port) fail('url_forbidden');\n  const source=CONFIG.sources.find(s=>s.enabled && (u.hostname===s.host || u.hostname==='www.'+s.host));\n  // Exact known hosts; do not follow arbitrary subdomains just because web_search allows them.\n  if(!source)fail('source_not_allowed');\n  if(u.hostname==='www.'+source.host)u.hostname=source.host;\n  u.hash='';\n  for(const key of [...u.searchParams.keys()])if(/^(utm_|yclid$|gclid$|fbclid$)/i.test(key))u.searchParams.delete(key);\n  u.searchParams.sort();\n  return u.toString();\n}\nfunction articleURL(value){return QUALITY.sourceURL(value).url;}\nfunction sourceName(url) { const host=new URL(canonicalURL(url)).hostname; return CONFIG.sources.find(s=>s.host===host).name; }\nfunction normalizedEvidence(text) { return text.normalize('NFC').replace(/\\s+/gu,' ').trim(); }\nfunction quoteIn(quote,body) {\n  const q=clean(quote,800,'evidence');\n  if(q.length<15 || !normalizedEvidence(body).includes(normalizedEvidence(q)))fail('evidence_not_found');\n  return q;\n}\n// read MUST come from an actual HTTP reader/extractor. Never pass a model-produced read object.\nfunction validateRead(read, searchURLs, win, now=new Date()) {\n  if(!read || read.transport!=='http' || read.status_code!==200 || !/^text\\/html(?:;|$)/i.test(read.content_type||''))fail('page_not_read');\n  const requested=articleURL(read.requested_url), url=articleURL(read.final_url);\n  if(!searchURLs.has(requested))fail('not_in_search_sources');\n  if(new URL(requested).hostname!==new URL(url).hostname)fail('cross_source_redirect');\n  const age=instant(now)-instant(read.fetched_at);\n  if(age<0 || age>3600000)fail('read_stale');\n  if(read.date_kind!=='published')fail('publication_date_required');\n  const raw=clean(read.publication_date_raw,150,'publication_date_raw');\n  const context=clean(read.publication_date_context,1200,'publication_date_context');\n  if(!normalizedEvidence(context).includes(normalizedEvidence(raw)))fail('date_context_mismatch');\n  if(/dateModified|дата обновления|последнее обновление/i.test(context))fail('updated_is_not_published');\n  const published=parseDateEvidence(raw);\n  if(published<isoDay(win.from) || published>isoDay(win.to))fail('outside_date_window');\n  return {url,requested_url:requested,published_date:published,article_text:clean(read.article_text,60000,'article_text'),\n    source_name:sourceName(url),fetched_at:read.fetched_at,publication_date_context:context};\n}\nfunction validateCandidate(candidate, read, searchURLs, win, now=new Date()) {\n  const page=validateRead(read,searchURLs,win,now);\n  if(!candidate || candidate.read_id!==read.read_id)fail('read_id_mismatch');\n  if(candidate.relevance!=='direct_personal_finance')fail('not_relevant_to_people');\n  if(!Object.hasOwn(STAGES,candidate.stage))fail('stage_unknown');\n  if(candidate.published_date!==page.published_date)fail('date_mismatch');\n  if(!Array.isArray(candidate.facts) || candidate.facts.length<1 || candidate.facts.length>4)fail('facts_invalid');\n  const facts=candidate.facts.map(f=>({text:clean(f.text,400,'fact'),evidence:quoteIn(f.evidence,page.article_text)}));\n  const stage_evidence=quoteIn(candidate.stage_evidence,page.article_text);\n  let effective_date=null;\n  if(candidate.effective_date!=null){\n    effective_date=isoDay(candidate.effective_date);\n    const evidence=quoteIn(candidate.effective_evidence,page.article_text);\n    const raw=clean(candidate.effective_date_raw,150,'effective_date_raw');\n    if(!normalizedEvidence(evidence).includes(normalizedEvidence(raw)) || parseDateEvidence(raw)!==effective_date)fail('effective_date_mismatch');\n    if(candidate.stage==='in_force' && effective_date>win.to)fail('not_in_force_yet');\n  }\n  // A verified date plus supporting words are mandatory before advertising that a new rule is in force.\n  if(candidate.stage==='in_force' && !effective_date)fail('effective_date_required');\n  return {url:page.url,source_name:page.source_name,published_date:page.published_date,\n    title:clean(candidate.title,150,'title'),summary:clean(candidate.summary,400,'summary'),\n    why:clean(candidate.why,220,'why'),stage:candidate.stage,stage_label:STAGES[candidate.stage],\n    effective_date,stage_evidence,facts,verified_at:page.fetched_at,\n    // Additional event clustering requires verified document IDs in the workflow; do not invent them.\n    dedup_key:page.url,format:'news_fact_pack_v1'};\n}\nfunction chooseTopics(items, excludedURLs=[], limit=CONFIG.max_topics) {\n  if(!Number.isInteger(limit)||limit<1||limit>CONFIG.max_topics)fail('limit_invalid');\n  const seen=new Set(excludedURLs.map(articleURL));\n  const result=[];\n  for(const item of [...items].sort((a,b)=>b.published_date.localeCompare(a.published_date))){\n    const key=articleURL(item.url);\n    if(seen.has(key))continue;\n    seen.add(key);result.push(item);if(result.length===limit)break;\n  }\n  return result;\n}\nfunction callbackData(batchId,itemId){\n  if(!/^[A-Za-z0-9_-]{1,16}$/.test(batchId)||!/^[A-Za-z0-9_-]{1,12}$/.test(itemId))fail('id_invalid');\n  return `news:${batchId}:${itemId}`;\n}\n// Advisory guard only. Perform and persist the state claim atomically AFTER this guard.\nfunction guardSelection({callback,batch,item,now=new Date()}) {\n  if(!callback||!batch||!item)fail('selection_missing');\n  if(String(callback.from_id)!==String(batch.owner_id)||String(callback.chat_id)!==String(batch.owner_id))fail('not_owner');\n  if(String(callback.message_id)!==String(item.message_id))fail('wrong_message');\n  if(callback.data!==callbackData(batch.id,item.id)||item.batch_id!==batch.id)fail('wrong_item');\n  if(instant(now)>=instant(batch.expires_at))fail('selection_expired');\n  if(batch.status!=='ready'||item.status!=='offered')fail('selection_not_available');\n  return {batch_id:batch.id,item_id:item.id,selection_key:`news:${batch.id}:${item.id}`};\n}\nfunction captionBudget(sourceURL) {\n  const suffix='\\n\\nИсточник: '+articleURL(sourceURL);\n  const body_limit=CONFIG.caption_limit_utf16-suffix.length;\n  if(body_limit<250)fail('source_url_too_long_for_caption');\n  return {suffix,body_limit,preferred_body_max:Math.min(800,body_limit)};\n}\nfunction composeNewsCaption(body,sourceURL) {\n  const {suffix,body_limit}=captionBudget(sourceURL);\n  const text=clean(body,body_limit,'caption_body');\n  // Model writes body only. Link is appended deterministically and retained through revision.\n  if(/https?:\\/\\//i.test(text))fail('unexpected_link_in_body');\n  return text+suffix;\n}\nfunction topicMessage(item){\n  const text=`${item.title}\\n\\n${item.summary}\\n\\nПочему это важно: ${item.why}\\n\\n${item.stage_label} · ${item.published_date}\\n${item.source_name}\\n${item.url}`;\n  return clean(text,4096,'topic_message');\n}\nfunction progressText(event,{count=0,days=7,partial=false}={}){\n  switch(event){\n    case 'accepted': return `Взято в работу. Ищу новости за последние ${days} дней.`;\n    case 'verifying': return 'Проверяю даты, источники и связь новостей с личными долгами.';\n    case 'ready': return `Готово. Тем на выбор: ${count}. Нажми «Подготовить пост» под подходящей новостью.`+(partial?' Часть источников проверить не удалось.':'');\n    case 'empty': return `За последние ${days} дней подходящих новостей с проверенным источником не нашлось.`+(days===7?' Можно расширить поиск командой /news 30.':'');\n    case 'partial_empty': return 'Полностью проверить источники не удалось; подтверждённых тем пока нет. Можно повторить поиск позже.';\n    case 'failed': return 'Поиск сейчас не завершился. Можно повторить /news позже. Существующие черновики сохранены.';\n    case 'selected': return 'Тема выбрана. Проверяю источник и готовлю пост.';\n    case 'expired': return 'Эта подборка устарела. Отправь /news, чтобы получить свежие темы.';\n    default: fail('progress_event_unknown');\n  }\n}\n\nreturn {CONFIG,STAGES,parseCommand,windowFor,parseDateEvidence,canonicalURL,articleURL,\n  validateRead,validateCandidate,chooseTopics,callbackData,guardSelection,captionBudget,\n  composeNewsCaption,topicMessage,progressText};})();\nconst RESP=(()=>{'use strict';\nconst core=NEWS;\nfunction modelName(model){\n  if(typeof model!=='string'||!model.trim()||model.includes('/')||/\\s/.test(model))throw Error('Configure a real OpenAI REST model ID with web_search support');\n  return model;\n}\nfunction searchRequest({model,instructions,days=7,topic='',now=new Date()}){\n  if(typeof instructions!=='string'||!instructions.trim())throw Error('Search instructions required');\n  const win=core.windowFor(days,now);\n  return {model:modelName(model),store:false,\n    instructions,\n    input:JSON.stringify({task:'Find recent official and editorial articles about personal finance, debts and rights in Russia',window:win,topic,max_candidates:core.CONFIG.max_candidates}),\n    tools:[{type:'web_search',external_web_access:true,filters:{allowed_domains:core.CONFIG.sources.filter(s=>s.enabled).map(s=>s.host)}}],\n    tool_choice:'required',include:['web_search_call.action.sources'],max_output_tokens:8000};\n}\nfunction outputJSON(response){\n  if(!response||response.status!=='completed'||response.error)throw Error('responses_not_completed');\n  const chunks=[];\n  for(const output of response.output||[]){\n    if(output.type!=='message')continue;\n    if(output.status && output.status!=='completed')throw Error('message_not_completed');\n    for(const part of output.content||[]){\n      if(part.type==='refusal')throw Error('model_refusal');\n      if(part.type==='output_text')chunks.push(part.text);\n    }\n  }\n  const text=chunks.join('\\n').trim().replace(/^```json\\s*/i,'').replace(/\\s*```$/,'');\n  let value;try{value=JSON.parse(text);}catch{throw Error('invalid_output_json');}\n  if(!value||Array.isArray(value)||typeof value!=='object')throw Error('invalid_output_object');\n  return value;\n}\nfunction parseDiscovery(response){\n  const value=outputJSON(response);\n  if(!(response.output||[]).some(o=>o.type==='web_search_call'&&o.status==='completed'&&o.action?.type==='search'))throw Error('web_search_not_executed');\n  const searchURLs=new Set();\n  function remember(url){try{searchURLs.add(core.articleURL(url));}catch{/* Skip unsupported sources. */}}\n  for(const output of response.output||[]){\n    if(output.type==='web_search_call'&&output.status==='completed'){\n      for(const s of output.action?.sources||[])remember(s.url);\n      if(output.action?.type==='open_page')remember(output.action.url);\n    }\n    if(output.type==='message')for(const p of output.content||[])for(const a of p.annotations||[])if(a.type==='url_citation')remember(a.url);\n  }\n  if(!Array.isArray(value.candidates)||value.candidates.length>core.CONFIG.max_candidates)throw Error('invalid_candidates');\n  const candidates=[],rejected=[];const seen=new Set();\n  for(const candidate of value.candidates){\n    try{\n      const url=core.articleURL(candidate.url);\n      if(!searchURLs.has(url))throw Error('candidate_not_in_search_sources');\n      if(seen.has(url))continue;\n      seen.add(url);candidates.push({url}); // Discard model guesses about dates/facts until the page is read.\n    }catch(error){rejected.push({code:error.code||error.message});}\n  }\n  return {candidates,search_urls:[...searchURLs],rejected,response_id:response.id||null};\n}\nfunction verificationRequest({model,instructions,reads,searchURLs,win,now=new Date()}){\n  if(!Array.isArray(reads)||!reads.length||reads.length>5)throw Error('verification_batch_size');\n  if(typeof instructions!=='string'||!instructions.trim())throw Error('Verification instructions required');\n  const ids=new Set();\n  const pages=reads.map(read=>{\n    if(typeof read.read_id!=='string'||!read.read_id||ids.has(read.read_id))throw Error('read_id_invalid');\n    ids.add(read.read_id);\n    const page=core.validateRead(read,searchURLs,win,now);\n    // Reader must extract the article rather than sending the entire site navigation.\n    if(page.article_text.length>22000)throw Error('article_needs_bounded_extraction');\n    return {read_id:read.read_id,published_date:page.published_date,url:page.url,article_text:page.article_text};\n  });\n  return {model:modelName(model),store:false,instructions,input:JSON.stringify({window:win,pages}),max_output_tokens:8000};\n}\nfunction parseVerification(response,reads,searchURLs,win,now=new Date()){\n  const value=outputJSON(response);\n  if(!Array.isArray(value.results)||value.results.length!==reads.length)throw Error('verification_results_incomplete');\n  const remaining=new Map(reads.map(r=>[r.read_id,r]));\n  const accepted=[],rejected=[];\n  for(const result of value.results){\n    const read=remaining.get(result.read_id);\n    if(!read)throw Error('unknown_or_duplicate_read_id');\n    remaining.delete(result.read_id);\n    if(result.accept===false){rejected.push({read_id:read.read_id,code:'editorial_exclusion',reason:String(result.reason||'').slice(0,400)});continue;}\n    if(result.accept!==true)throw Error('verification_decision_missing');\n    try{accepted.push(core.validateCandidate(result,read,searchURLs,win,now));}\n    catch(error){rejected.push({read_id:read.read_id,code:error.code||error.message});}\n  }\n  return {accepted,rejected};\n}\n\nreturn {searchRequest,outputJSON,parseDiscovery,verificationRequest,parseVerification};})();\nfunction snapshotOf(topic){\n if(topic.format!=='news_topic_v2'&&topic.format!=='news_fact_pack_v2')throw Error('news_format_invalid');\n const snap=topic.snapshot;\n if(!snap||snap.url!==NEWS.articleURL(topic.url)||snap.published_date!==topic.published_date)throw Error('snapshot_source_mismatch');\n const blocks=QUALITY.sourceBlocks(snap.article_text,snap.document_key);\n if(JSON.stringify(blocks.blocks)!==JSON.stringify(snap.blocks))throw Error('snapshot_blocks_mismatch');\n return snap;\n}\nfunction strictRequest(instructions,input,schema,name,max=4500){return {model:'gpt-5.6-sol',reasoning:{effort:'low'},store:false,max_output_tokens:max,instructions,input:JSON.stringify(input),text:{format:{type:'json_schema',name,strict:true,schema}}};}\nfunction apiResult(r,prefix){\n if(r.statusCode!==200)throw Error(prefix+(r.statusCode?'_http_'+r.statusCode+(r.statusCode>=500?'_outcome_unknown':''):'_outcome_unknown'));\n if(r.body?.status==='incomplete')throw Error(prefix+'_incomplete');\n if(r.body?.output?.some(x=>x.content?.some(c=>c.type==='refusal')))throw Error(prefix+'_refusal');\n try{return RESP.outputJSON(r.body);}catch{throw Error(prefix+'_malformed_json');}\n}\nfunction verifiedFacts(topic,result){\n const snap=snapshotOf(topic);\n if(result.document_key!==snap.document_key)throw Error('wrong_source_document');\n if(result.status!=='ready')throw Error('selected_needs_source_check');\n if(!['news','analysis','opinion','explanation','statistics','court','bill','adopted','in_force'].includes(result.content_type))throw Error('selected_content_type_invalid');\n // A news report is not itself an enacted legal document. Narrow attributed reporting remains available.\n if(result.content_type==='in_force')throw Error('selected_primary_document_required');\n if(!Array.isArray(result.facts)||!result.facts.length||result.facts.length>4)throw Error('selected_facts_invalid');\n const facts=result.facts.map(f=>{if(typeof f.text!=='string'||!f.text.trim()||f.text.length>700||!Array.isArray(f.qualifiers)||typeof f.attribution!=='string')throw Error('selected_fact_invalid');return {...f,source_blocks:QUALITY.resolveEvidenceRefs(f,snap)};});\n return {...topic,format:'news_fact_pack_v2',verification_state:'facts_selected',facts,content_type:result.content_type,limitations:result.limitations,selected_at:new Date().toISOString()};\n}\nfunction previewInput(s){\n const fact=JSON.parse(s.fact_pack);\n if(NEWS.articleURL(fact.url)!==s.source_url||NEWS.composeNewsCaption(s.body,s.source_url)!==s.caption)throw Error('news_caption_source_mismatch');\n let source;\n if(fact.format==='news_fact_pack_v2'){source=snapshotOf(fact);for(const f of fact.facts)QUALITY.resolveEvidenceRefs(f,source);}\n else if(fact.format==='news_fact_pack_v1')source=QUALITY.sourceBlocks([fact.stage_evidence,...fact.facts.map(f=>f.evidence)].filter(Boolean).join('\\n\\n'),'legacy_saved_evidence');\n else throw Error('news_facts_not_selected');\n return {caption_body:s.body,cover_title:s.cover_title,cover_subtitle:s.cover_subtitle,facts:fact.facts,content_type:fact.content_type||fact.stage,limitations:fact.limitations||[],source,source_url:s.source_url,legacy_evidence_only:fact.format==='news_fact_pack_v1'};\n}\nfunction assessPreview(result){\n if(typeof result.pass!=='boolean'||!Array.isArray(result.issues)||result.issues.length>10)throw Error('preview_malformed_json');\n if(result.pass&&result.issues.length)throw Error('preview_contradictory_result');\n if(!result.pass&&!result.issues.length)throw Error('preview_empty_failure');\n for(const i of result.issues)if(!['caption_body','cover_title','cover_subtitle'].includes(i.field)||typeof i.fragment!=='string'||!i.fragment||typeof i.suggested_replacement!=='string'||typeof i.problem!=='string')throw Error('preview_issue_invalid');\n return result;\n}\nfunction repairPreview(s,issues){\n const out={...s};let exact=null;try{exact=JSON.parse(s.exact_revision_json||'null');}catch{}\n // A precise owner edit may only be corrected inside its replacement span. The saved cover stays byte-identical.\n const bounds=exact&&!exact.validation_error?{start:s.body.indexOf(exact.newText),length:exact.newText?.length||0}:null;\n if(bounds&&(bounds.start<0||s.body.indexOf(exact.newText,bounds.start+1)>=0))throw Error('preview_exact_span_ambiguous');\n const edits={};\n for(const issue of issues){const key=issue.field==='caption_body'?'body':issue.field,old=out[key];if(typeof old!=='string')throw Error('preview_field_missing');const at=old.indexOf(issue.fragment);if(at<0||old.indexOf(issue.fragment,at+1)>=0)throw Error('preview_repair_ambiguous');\n if(bounds&&(key!=='body'||at<bounds.start||at+issue.fragment.length>bounds.start+bounds.length))throw Error('preview_exact_requires_owner_edit');\n (edits[key]||=[]).push({at,end:at+issue.fragment.length,text:issue.suggested_replacement});}\n for(const [key,changes] of Object.entries(edits)){changes.sort((a,b)=>b.at-a.at);let last=Infinity;for(const e of changes){if(e.end>last)throw Error('preview_repair_overlap');out[key]=out[key].slice(0,e.at)+e.text+out[key].slice(e.end);last=e.at;}}\n if(!out.cover_title||out.cover_title.length>70||out.cover_subtitle.length>110)throw Error('preview_cover_bounds');\n out.caption=NEWS.composeNewsCaption(out.body,out.source_url);out.text=out.caption;out.channel_caption=out.caption;out.caption_length=out.caption.length;\n if(bounds){exact.body=out.body;exact.caption=out.caption;out.exact_revision_json=JSON.stringify(exact);}\n return out;\n}\nconst input=$('Вход статуса').first().json,r=$json,event=input._notification_event;\nconst rules={accepted:[10,['searching']],verifying:[20,['verifying']],finished:[30,['ready','empty','partial_empty','failed']]};const rule=rules[event],base={...r,_notify:false};\nif(!rule||r.owner_chat_id!=='710000001'||r.operation_id!==input.operation_id||!rule[1].includes(r.status)||r.notification_lock||['unknown','missing','disabled'].includes(r.notification_state)||Number(r.notification_retry_after||0)>Date.now()||Number(r.notification_rank||0)>rule[0])return {json:base};\nconst text=NEWS.progressText(event==='finished'?r.status:event,{days:r.days,count:r.accepted_count||0,partial:!!r.partial});\nif(r.notification_text===text)return {json:base};\nreturn {json:{...base,_notify:true,_notify_rank:rule[0],_notify_text:text,_notify_lock:r.operation_id+'|'+$execution.id,_notify_method:r.status_message_id?'editMessageText':'sendMessage'}};\n\n})();\nif(__result===null||__result===undefined)return __result;\nconst __a=bflAccess();\nconst __attach=(item)=>({...item,json:{...item.json,_bfl:__a,operation_actor_user_id:__a.user_id,operation_actor_chat_id:__a.chat_id,created_by_user_id:item.json.created_by_user_id||$json.created_by_user_id||__a.user_id}});\nreturn Array.isArray(__result)?__result.map(__attach):__attach(__result);"
      },
      "position": [
        500,
        0
      ]
    },
    {
      "id": "86141d29-1e15-4a93-b0e8-9c76c31028e5",
      "name": "Статус нужно обновить?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "leftValue": "={{ $json._notify === true }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      },
      "position": [
        750,
        0
      ]
    },
    {
      "id": "8a18603d-33d3-4653-acde-13854eff5cda",
      "name": "Закрепить обновление статуса",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "parameters": {
        "resource": "row",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "BFLNewsBatchV04",
          "cachedResultName": "bfl_content_versions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "id",
              "condition": "eq",
              "keyValue": "={{ $(\"Выбрать актуальный статус\").first().json.id }}"
            },
            {
              "keyName": "owner_chat_id",
              "condition": "eq",
              "keyValue": "710000001"
            },
            {
              "keyName": "status",
              "condition": "eq",
              "keyValue": "={{ $json.status }}"
            },
            {
              "keyName": "operation_id",
              "condition": "eq",
              "keyValue": "={{ $json.operation_id }}"
            },
            {
              "keyName": "notification_rank",
              "condition": "eq",
              "keyValue": "={{ $json.notification_rank }}"
            },
            {
              "keyName": "notification_lock",
              "condition": "eq",
              "keyValue": ""
            },
            {
              "keyName": "notification_state",
              "condition": "eq",
              "keyValue": "={{ $json.notification_state }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "notification_rank": "={{ $json._notify_rank }}",
            "notification_lock": "={{ $json._notify_lock }}"
          },
          "schema": [
            {
              "id": "notification_rank",
              "displayName": "notification_rank",
              "type": "number"
            },
            {
              "id": "notification_lock",
              "displayName": "notification_lock",
              "type": "string"
            }
          ]
        }
      },
      "position": [
        1000,
        0
      ],
      "alwaysOutputData": true
    },
    {
      "id": "30fcd24f-e6dd-4803-ad75-1fbbd8bfb885",
      "name": "Блокировка статуса получена?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "leftValue": "={{ !!$json.id && $json.notification_lock === $(\"Выбрать актуальный статус\").first().json._notify_lock }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      },
      "position": [
        1250,
        0
      ]
    },
    {
      "id": "80ca1ef7-85b6-4091-a9ab-234ea3f6032c",
      "name": "Есть сообщение статуса?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "leftValue": "={{ !!$json.status_message_id }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      },
      "position": [
        1500,
        0
      ]
    },
    {
      "id": "d67476cf-945b-41c4-a615-91fafab8f7c6",
      "name": "Отправить сообщение статуса",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "parameters": {
        "method": "POST",
        "url": "http://bfl-collaboration:8084/telegram",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ {ticket:$('Вход статуса').first().json._bfl.ticket,purpose:\"progress\",context:$json,method:\"sendMessage\",body:{\"text\":($(\"Выбрать актуальный статус\").first().json._notify_text),\"parse_mode\":\"HTML\",\"disable_web_page_preview\":true} } }}",
        "options": {
          "timeout": 150000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "position": [
        0,
        260
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "CREDENTIAL_01",
          "name": "REDACTED httpHeaderAuth"
        }
      },
      "onError": "continueRegularOutput",
      "retryOnFail": false
    },
    {
      "id": "1021d7ef-63f2-4efe-8e97-a133705295ea",
      "name": "Изменить сообщение статуса",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "parameters": {
        "method": "POST",
        "url": "http://bfl-collaboration:8084/telegram",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ {ticket:$('Вход статуса').first().json._bfl.ticket,purpose:\"progress\",context:$json,method:\"editMessageText\",body:{\"text\":($(\"Выбрать актуальный статус\").first().json._notify_text),\"parse_mode\":\"HTML\"} } }}",
        "options": {
          "timeout": 150000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "position": [
        250,
        260
      ],
      "credentials": {
        "httpHeaderAuth": {
          "id": "CREDENTIAL_01",
          "name": "REDACTED httpHeaderAuth"
        }
      },
      "onError": "continueRegularOutput",
      "retryOnFail": false
    },
    {
      "id": "ed7ffea9-abe3-48e2-bd5a-5aea26bcc619",
      "name": "Проверить результат уведомления",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "function bflAccess(){return $('Вход статуса').first().json._bfl;}\nconst __result = await (async()=>{\nconst p=$('Закрепить обновление статуса').first().json;\nconst desired=$('Выбрать актуальный статус').first().json;\nlet ok=$json.ok===true;\nconst result=$json.result;\nconst existing=String(p.status_message_id||'');\nlet messageId=existing,error='',state='idle',retryAt=0;\nlet serial='';try{serial=JSON.stringify($json.error||$json);}catch{}\n// Only derived code/state is returned; raw Telegram errors never enter owner text.\nif(!ok&&/message is not modified/i.test(serial)&&existing)ok=true;\nif(ok){\n if(result&&typeof result==='object'&&String(result.chat?.id)===bflAccess().chat_id&&Number.isSafeInteger(result.message_id))messageId=String(result.message_id);\n if(!messageId){ok=false;error='notification_result_unconfirmed';state='unknown';}\n}else{\n let retry=Number($json.parameters?.retry_after||$json.error?.parameters?.retry_after||$json.error?.cause?.parameters?.retry_after||0);\n if(!retry){const m=/retry_after[^\\d]{0,12}(\\d+)/i.exec(serial);if(m)retry=Number(m[1]);}\n if(retry>0||/429|Too Many Requests/i.test(serial)){error='notification_rate_limited';state='rate_limited';retryAt=Date.now()+Math.max(retry,1)*1000;}\n else if(/message to edit not found|message can.t be edited/i.test(serial)){error='notification_message_missing';state='missing';}\n else if(/400|403|Bad Request|Forbidden/i.test(serial)){error='notification_rejected';state='disabled';}\n else{error='notification_outcome_unknown';state='unknown';}\n}\nreturn {json:{...p,status_message_id:messageId,notification_text:ok?desired._notify_text:p.notification_text||'',notification_error:error,notification_state:state,notification_retry_after:retryAt,_notify_ok:ok}};\n\n})();\nif(__result===null||__result===undefined)return __result;\nconst __a=bflAccess();\nconst __attach=(item)=>({...item,json:{...item.json,_bfl:__a,operation_actor_user_id:__a.user_id,operation_actor_chat_id:__a.chat_id,created_by_user_id:item.json.created_by_user_id||$json.created_by_user_id||__a.user_id}});\nreturn Array.isArray(__result)?__result.map(__attach):__attach(__result);"
      },
      "position": [
        500,
        260
      ]
    },
    {
      "id": "03038e48-93e7-4ee5-96d2-1b54ae79d8a4",
      "name": "Сохранить результат уведомления",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1.1,
      "parameters": {
        "resource": "row",
        "operation": "update",
        "dataTableId": {
          "__rl": true,
          "mode": "id",
          "value": "BFLNewsBatchV04",
          "cachedResultName": "bfl_content_versions"
        },
        "matchType": "allConditions",
        "filters": {
          "conditions": [
            {
              "keyName": "id",
              "condition": "eq",
              "keyValue": "={{ $(\"Закрепить обновление статуса\").first().json.id }}"
            },
            {
              "keyName": "owner_chat_id",
              "condition": "eq",
              "keyValue": "710000001"
            },
            {
              "keyName": "operation_id",
              "condition": "eq",
              "keyValue": "={{ $(\"Закрепить обновление статуса\").first().json.operation_id }}"
            },
            {
              "keyName": "notification_lock",
              "condition": "eq",
              "keyValue": "={{ $(\"Закрепить обновление статуса\").first().json.notification_lock }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "status_message_id": "={{ $json.status_message_id }}",
            "notification_text": "={{ $json.notification_text }}",
            "notification_error": "={{ $json.notification_error }}",
            "notification_state": "={{ $json.notification_state }}",
            "notification_retry_after": "={{ $json.notification_retry_after }}",
            "notification_lock": ""
          },
          "schema": [
            {
              "id": "status_message_id",
              "displayName": "status_message_id",
              "type": "string"
            },
            {
              "id": "notification_text",
              "displayName": "notification_text",
              "type": "string"
            },
            {
              "id": "notification_error",
              "displayName": "notification_error",
              "type": "string"
            },
            {
              "id": "notification_state",
              "displayName": "notification_state",
              "type": "string"
            },
            {
              "id": "notification_retry_after",
              "displayName": "notification_retry_after",
              "type": "number"
            },
            {
              "id": "notification_lock",
              "displayName": "notification_lock",
              "type": "string"
            }
          ]
        }
      },
      "position": [
        750,
        260
      ],
      "alwaysOutputData": true
    },
    {
      "id": "9f611ae1-1450-4d90-999a-f4b7c7c5dfaa",
      "name": "Один повтор уведомления разрешён?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "conditions": [
            {
              "leftValue": "={{ $json.notification_state === \"rate_limited\" && Number($(\"Вход статуса\").first().json._notification_attempt||0)<1 && $json.notification_retry_after-Date.now()<=10000 }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        }
      },
      "position": [
        1000,
        260
      ]
    },
    {
      "id": "259e84f5-3c7d-43af-b002-85d2c032a658",
      "name": "Дождаться retry_after",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "parameters": {
        "amount": "={{ Math.max(0.01,($json.notification_retry_after-Date.now())/1000) }}",
        "unit": "seconds"
      },
      "position": [
        1250,
        260
      ]
    },
    {
      "id": "affb1651-b4de-4ae5-9187-2f52f5ab8291",
      "name": "Повторить только актуальное уведомление",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "function bflAccess(){return $('Вход статуса').first().json._bfl;}\nconst __result = await (async()=>{\nreturn {json:{...$('Вход статуса').first().json,_notification_attempt:1}};\n})();\nif(__result===null||__result===undefined)return __result;\nconst __a=bflAccess();\nconst __attach=(item)=>({...item,json:{...item.json,_bfl:__a,operation_actor_user_id:__a.user_id,operation_actor_chat_id:__a.chat_id,created_by_user_id:item.json.created_by_user_id||$json.created_by_user_id||__a.user_id}});\nreturn Array.isArray(__result)?__result.map(__attach):__attach(__result);"
      },
      "position": [
        1500,
        260
      ]
    },
    {
      "id": "4ea4d9b3-be0b-4cea-b543-6aeca52c0a9f",
      "name": "Один повтор служебного статуса",
      "type": "n8n-nodes-base.executeWorkflow",
      "typeVersion": 1.3,
      "parameters": {
        "source": "database",
        "workflowId": {
          "__rl": true,
          "mode": "id",
          "value": "BFLNewsStatus04"
        },
        "mode": "once",
        "options": {
          "waitForSubWorkflow": true
        }
      },
      "position": [
        0,
        520
      ],
      "onError": "continueRegularOutput",
      "alwaysOutputData": true,
      "retryOnFail": false
    },
    {
      "id": "da14aedf-2df7-4f8d-b248-d604143c58c6",
      "name": "Вернуть управление",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "parameters": {
        "mode": "runOnceForEachItem",
        "language": "javaScript",
        "jsCode": "function bflAccess(){return $('Вход статуса').first().json._bfl;}\nconst __result = await (async()=>{\nreturn {json:{notification_complete:true}};\n})();\nif(__result===null||__result===undefined)return __result;\nconst __a=bflAccess();\nconst __attach=(item)=>({...item,json:{...item.json,_bfl:__a,operation_actor_user_id:__a.user_id,operation_actor_chat_id:__a.chat_id,created_by_user_id:item.json.created_by_user_id||$json.created_by_user_id||__a.user_id}});\nreturn Array.isArray(__result)?__result.map(__attach):__attach(__result);"
      },
      "position": [
        250,
        520
      ]
    },
    {
      "id": "b3b639bb-7d50-51d2-ac44-69c18cb34056",
      "name": "Участник → Один повтор служебного статуса",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -2200,
        1500
      ],
      "parameters": {
        "jsCode": "const a=$('Вход статуса').first().json._bfl;if(!a?.ticket)throw Error(\"actor_context_missing\");return $input.all().map((i,k)=>({...i,json:{...i.json,_bfl:a,operation_actor_user_id:a.user_id,operation_actor_chat_id:a.chat_id},pairedItem:{item:k}}));"
      }
    },
    {
      "id": "d259a24d-5a0d-5930-9aae-9bc4c48f007e",
      "name": "Записать контекст выполнения",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.4,
      "position": [
        -2300,
        1800
      ],
      "parameters": {
        "method": "POST",
        "url": "http://bfl-collaboration:8084/execution-context",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ {ticket:$json._bfl.ticket,context:$json,execution_id:String($execution.id)} }}",
        "options": {
          "timeout": 60000,
          "response": {
            "response": {
              "responseFormat": "json"
            }
          }
        }
      },
      "credentials": {
        "httpHeaderAuth": {
          "id": "CREDENTIAL_01",
          "name": "REDACTED httpHeaderAuth"
        }
      },
      "retryOnFail": false
    }
  ],
  "connections": {
    "Вход статуса": {
      "main": [
        [
          {
            "node": "Записать контекст выполнения",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Прочитать состояние операции": {
      "main": [
        [
          {
            "node": "Выбрать актуальный статус",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Выбрать актуальный статус": {
      "main": [
        [
          {
            "node": "Статус нужно обновить?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Статус нужно обновить?": {
      "main": [
        [
          {
            "node": "Закрепить обновление статуса",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Вернуть управление",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Закрепить обновление статуса": {
      "main": [
        [
          {
            "node": "Блокировка статуса получена?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Блокировка статуса получена?": {
      "main": [
        [
          {
            "node": "Есть сообщение статуса?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Вернуть управление",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Есть сообщение статуса?": {
      "main": [
        [
          {
            "node": "Изменить сообщение статуса",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Отправить сообщение статуса",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Отправить сообщение статуса": {
      "main": [
        [
          {
            "node": "Проверить результат уведомления",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Изменить сообщение статуса": {
      "main": [
        [
          {
            "node": "Проверить результат уведомления",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Проверить результат уведомления": {
      "main": [
        [
          {
            "node": "Сохранить результат уведомления",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Сохранить результат уведомления": {
      "main": [
        [
          {
            "node": "Один повтор уведомления разрешён?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Один повтор уведомления разрешён?": {
      "main": [
        [
          {
            "node": "Дождаться retry_after",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Вернуть управление",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Дождаться retry_after": {
      "main": [
        [
          {
            "node": "Повторить только актуальное уведомление",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Повторить только актуальное уведомление": {
      "main": [
        [
          {
            "node": "Участник → Один повтор служебного статуса",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Один повтор служебного статуса": {
      "main": [
        [
          {
            "node": "Вернуть управление",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Участник → Один повтор служебного статуса": {
      "main": [
        [
          {
            "node": "Один повтор служебного статуса",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Записать контекст выполнения": {
      "main": [
        [
          {
            "node": "Прочитать состояние операции",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 20,
    "saveDataSuccessExecution": "all",
    "saveDataErrorExecution": "all",
    "saveExecutionProgress": false,
    "errorWorkflow": "BFLEditorError20260916"
  },
  "activeVersionId": "e9b54b87-eb85-49a5-90b1-bc99af2f4269"
}