Skip to content

Importador de flows Blip

Ferramenta de migração one-shot que converte o JSON exportado do Blip para um FlowDefinition nativo do flow-ai. O processo passa por um formato intermediário revisável (ImportDraft) antes de criar o flow definitivo.

Para a arquitetura geral e os endpoints, veja também docs/blip-import.md no repositório.


Requisitos para importação

Formato do JSON

O arquivo deve ser um export direto do portal Blip. A estrutura mínima esperada:

json
{
  "flow": {
    "stateId1": { "$title": "...", "$contentActions": [], ... },
    "stateId2": { ... }
  }
}
  • O campo flow deve estar presente e ser um objeto não vazio.
  • Os estados especiais onboarding e fallback são detectados pelo nome da chaveflow.onboarding e flow.fallback — não por campos internos.
  • Estados de atendimento humano são detectados pela chave começando com desk: ou pela presença de ForwardToDesk nas entering actions.

Formatos de action suportados pelo export Blip

O exportador Blip serializa actions de duas formas diferentes. Ambas são suportadas:

FormatoEstrutura
wrapped{ action: { type, $typeConfig, … }, $invalid: bool }
direct{ type, $typeConfig, $invalid: bool, … }

A configuração de cada action fica em $typeConfig, não em settings. O código suporta ambas as chaves (fallback settings ?? $typeConfig).


Requisitos para interpretação automática de scripts

Scripts Blip em $contentActions com type: "ExecuteScript" são classificados em cinco padrões. Apenas Padrão A e Padrão B são convertidos automaticamente em conteúdo nativo. Os demais exigem revisão manual.

Padrão A — array multi-canal com filtro WhatsApp

Requisito: o script deve construir um array de mensagens e filtrar pelo canal WhatsApp.

Estrutura obrigatória — variante 1 (source string):

js
var messages = [
  // outros canais...
  {
    source: "whatsapp",
    content: {
      type: "interactive",
      interactive: { type: "button" /* ou "list" */, … }
    }
  }
]
// O script DEVE conter .filter( e source ou whatsapp
var msg = messages.filter(x => x.source === "whatsapp")[0]

Variante 2 (source array + messageDynamic):

js
{
  source: ["whatsapp"],
  messageDynamic: {
    type: "interactive",
    interactive: { type: "button" /* ou "list" */, … }
  }
}

Para ser convertido como botões (InteractiveButtonsContent):

  • interactive.type deve ser "button"
  • interactive.action.buttons[] deve ter entre 1 e 3 itens
  • Cada botão deve ter reply.id e reply.title
  • interactive.body.text deve estar presente

Para ser convertido como lista (InteractiveListContent):

  • interactive.type deve ser "list"
  • interactive.action.sections[] deve ter pelo menos 1 seção com pelo menos 1 row
  • interactive.body.text deve estar presente

Para ser convertido como texto (TextContent):

  • A branch WhatsApp não tem campo interactive
  • Deve ter campo text ou body na raiz da branch

Template literals (`texto ${expr}`) são sanitizados antes do parse JSON: expressões ${expr} viram {{varName}} para preservar a referência.

Padrão B — texto simples (replaceAll / texto direto)

Requisito: o script deve conter ao menos um dos seguintes indicadores:

  • A string replaceAll (para remoção de asterisco markdown)
  • A string replaceAsterisk
  • Definição de mimeType text/plain sem .filter(

Extração do texto — tentada na seguinte ordem de prioridade:

  1. result.message = "…" ou result.message = '…' ou result.message = \…``
  2. Qualquer message = "…" (case-insensitive, captura textMessage com M maiúsculo)
  3. input.message = "…"
  4. Primeira declaração let/var/const varName = "texto com 3+ chars"
  5. Primeira declaração let/var/const varName = 'texto com 3+ chars'

Se nenhum padrão capturar texto, o body fica vazio.

Escape sequences (\n, \t, \\, \", \') são desescapadas para os caracteres reais antes de salvar — \n vira newline de verdade.

Padrões C, D, E — não convertidos automaticamente

PadrãoIndicadorResultado
C.map( / .forEach( / .reduce( + application/vnd.lime.list+jsonexecuteScript + needs-review
DJSON.parse / JSON.stringify / .map( sem list MIMEexecuteScript + needs-review
ENenhum dos anterioresexecuteScript + needs-review

Como o mapeamento é implementado

Classificação de padrão (classifyScript)

A classificação opera sobre o texto-fonte do script, extraído de $typeConfig.source ou $typeConfig.function:

ts
// blip-script-interpreter.ts
export function classifyScript(action: BlipAction): ScriptPattern {
  const script = extractScriptSource(action)
  if (!script) return "D"

  // Padrão A: array messages com .filter(source === "whatsapp")
  if (script.includes(".filter(") && (script.includes("source") || script.includes("whatsapp"))) {
    return "A"
  }

  // Padrão B: texto simples com replaceAll('*','') ou single string assignment
  if (
    script.includes("replaceAll") ||
    script.includes("replaceAsterisk") ||
    (script.includes("mimeType") && script.includes('"text/plain"') && !script.includes(".filter("))
  ) {
    return "B"
  }

  // Padrão C: lista dinâmica construída a partir de variável/contexto
  if (
    (script.includes("map(") || script.includes("forEach(") || script.includes("reduce(")) &&
    script.includes("application/vnd.lime.list+json")
  ) {
    return "C"
  }

  // Padrão D: transform de resposta de API / extração de campo
  if (
    script.includes("JSON.parse") ||
    script.includes("JSON.stringify") ||
    (script.includes(".map(") && !script.includes("application/vnd.lime.list+json"))
  ) {
    return "D"
  }

  return "E"
}

Ponto crítico: o exportador Blip armazena a configuração em $typeConfig, não em settings. O helper getConfig tenta as duas chaves para compatibilidade:

ts
function getConfig(action: BlipAction): Record<string, unknown> | undefined {
  return action.settings ?? action.$typeConfig
}

function extractScriptSource(action: BlipAction): string | null {
  const config = getConfig(action)
  return (config?.source as string) ?? (config?.function as string) ?? null
}

Extração do branch WhatsApp (Padrão A)

Para scripts com um array multi-canal, o importador localiza e isola apenas o objeto correspondente ao WhatsApp:

Variante 1source: "whatsapp" + content: { … }:

ts
// Regex captura o objeto content logo após source: "whatsapp"
const stdMatch = sanitized.match(
  /source\s*:\s*["']whatsapp["']\s*,\s*content\s*:\s*(\{[\s\S]*?\})\s*\}/
)

Se o JSON.parse falhar (estrutura aninhada), um parser de chaves balanceadas (extractBalancedBracesAt) percorre o texto caractere por caractere contando { e } até encontrar o bloco completo.

Variante 2source: ["whatsapp"] + messageDynamic: { … }:

ts
const waArrayIdx = sanitized.search(/source\s*:\s*\[\s*"whatsapp"\s*\]/)
// Localiza messageDynamic após o índice e extrai o objeto com chaves balanceadas
const dynMatch = fromWa.match(/messageDynamic\s*:/)
const extracted = extractBalancedBracesAt(fromWa, braceStart)

Sanitização de template literals

Antes do JSON.parse, template literals JavaScript são convertidos para strings JSON seguras, preservando as referências de variável:

ts
function sanitizeTemplateLiterals(script: string): string {
  return script.replace(/`([^`]*)`/gs, (_full, inner: string) => {
    // ${expr} → {{expr}} para preservar a referência no resultado final
    const converted = inner.replace(/\$\{([^}]+)\}/g, (_m, expr) => {
      const name = expr.trim().match(/[\w.]+/)?.[0] ?? "var"
      return `{{${name}}}`
    })
    // Escapa o conteúdo para ser embutido em JSON como string
    const escaped = converted
      .replace(/\\/g, "\\\\")
      .replace(/"/g, '\\"')
      .replace(/\r\n|\n|\r/g, "\\n")
      .replace(/\t/g, "\\t")
    return `"${escaped}"`
  })
}

Extração de conteúdo (botões / lista / texto)

Após isolar o branch WhatsApp, o tipo do conteúdo é determinado por interactive.type:

ts
function extractPatternA(script: string): ContentMessage[] | null {
  const waBlock = extractWhatsappBranch(script)
  if (!waBlock) return null

  const interactive = waBlock.interactive as Record<string, unknown> | undefined

  if (!interactive) {
    // Branch sem interactive → texto puro
    const text = (waBlock.text as string) ?? (waBlock.body as string)
    if (text) return [buildTextContent(text)]
    return null
  }

  if (interactive.type === "button") return buildButtonsContent(interactive) ? [buildButtonsContent(interactive)!] : null
  if (interactive.type === "list")   return buildListContent(interactive)   ? [buildListContent(interactive)!]   : null
  return null
}

Mapeamento de botões (interactive.type === "button"):

  • interactive.action.buttons[], limita a 3
  • Cada botão: reply.idid, reply.titletitle
  • interactive.body.textbody

Mapeamento de lista (interactive.type === "list"):

  • interactive.action.sections[], limita rows ao total de 10
  • Por row: id, title, description (opcional)
  • interactive.action.buttonbuttonText (default "Ver opções")
  • interactive.body.textbody

Em todos os campos de texto, unescapeJs converte sequências de escape do JavaScript para os caracteres reais antes de salvar:

ts
function unescapeJs(s: string): string {
  return s
    .replace(/\\n/g, "\n")
    .replace(/\\r/g, "\r")
    .replace(/\\t/g, "\t")
    .replace(/\\"/g, '"')
    .replace(/\\'/g, "'")
    .replace(/\\\\/g, "\\")
}

O array ContentMessage[] resultante é embrulhado num ContentMap no bloco final: { wa: [...], ig: [...], wc: [...] } com o mesmo conteúdo replicado nos três canais (a importação Blip é modelada como WhatsApp).

Extração de texto (Padrão B)

Os patterns de regex são tentados em ordem de especificidade:

ts
const patterns: RegExp[] = [
  /result\.message\s*=\s*["'`]([\s\S]*?)["'`]/,      // result.message = "..."
  /message\s*=\s*["'`]([\s\S]*?)["'`]/i,              // message = / textMessage =
  /input\.message\s*=\s*["'`]([\s\S]*?)["'`]/,        // input.message = "..."
  /(?:let|var|const)\s+\w+\s*=\s*"([^"]{3,})"/,       // const x = "texto longo"
  /(?:let|var|const)\s+\w+\s*=\s*'([^']{3,})'/,       // const x = 'texto longo'
]

O primeiro match com conteúdo não-vazio é retornado. Se nenhum corresponder, o body fica como string vazia.

Resolução indireta via variável

Quando um $contentActions item não tem script direto mas referencia uma variável de script via type: "{{VarName@mimeType}}", o importador percorre $enteringCustomActions procurando o ExecuteScript que produz VarName:

ts
// Pseudo-código da lógica em blip-converter.ts
const mimeMatch = rawType?.match(/^\{\{(\w+)@mimeType\}\}$/)
if (mimeMatch) {
  const varName = mimeMatch[1]
  const scriptAction = enteringActions.find(a =>
    a.type === "ExecuteScript" &&
    getConfig(a)?.outputVariable === varName
  )
  if (scriptAction) {
    // Interpreta o script encontrado como se fosse um ExecuteScript direto
    return interpretMessageScript(scriptAction, flags)
  }
}
  • mimeType dinâmico ({{VarName@mimeType}}): busca o ExecuteScript em $enteringCustomActions cujo outputVariable === VarName e interpreta seu padrão.
  • referência simples (content: "{{VarName}}"): mesma busca; se o script não for encontrado, preserva a referência literal como TextContent.body.

Cenários de conversão

Tipos de bloco

Condição no estado BlipTipo geradoFlags nos warnings
Chave começa com desk:humanAttendance
Tem ForwardToDesk nas entering actionshumanAttendance
Chave é onboardingstandardonboarding:trueonboardingBlockId
Chave é fallbackstandardfallback:truedefaultExceptionBlockId
$invalid: truequalquerneeds-review em content
demaisstandard

Actions (entering / leaving)

Action BlipAction flow-aiStatus
SetVariablesetContextVariableauto
ExecuteScript padrão A/Bdescartado (flag removeInterpreted)auto
ExecuteScript padrão C/D/EexecuteScriptauto (se scripts.logic ligado)
ProcessHttphttpCallauto; needs-review se URL contém msging.net / resource.urlBlip (take.net gera apenas warning, não needs-review)
ProcessCommandhttpCallneeds-review sempre
MergeContactN × setContactVariable (campos + entradas em extras)auto
TrackEventrecordEventauto (requer mapeamento na Fase 2)
ForwardToDeskdescartadaauto (implícita no humanAttendance)
RedirectredirectToBotauto (requer stubFlowId configurado)
tipo desconhecidodescartada + warning

Conteúdo inline (SendRawMessage / SendMessage)

mimeTypeConversão
ausente ou text/plainTextContent
application/vnd.lime.select+json≤ 3 opções → InteractiveButtonsContent; senão → TextContent
application/vnd.lime.collection+jsonTextContent com placeholder
application/vnd.lime.chatstate+jsonignorado — converte em typingMs na mensagem seguinte
qualquer outroTextContent (campos text / body / value / stringify)

Indicadores de digitação (chatstate)

Ações SendRawMessage ou SendMessage com mimeType chatstate+json precedendo uma mensagem são removidas do array e seu content.interval (number ou string) vira o campo typingMs do primeiro ContentMessage da mensagem seguinte.

Roteamento

Blipflow-ai
$conditionOutputs[].stateIdoutputConditions[].target.blockId (via blockIdMap)
$defaultOutput.stateIddefaultOutput.blockId
ausente{ type: "endAttendance" }

Comparadores suportados: equals, notEquals, contains, notContains, startsWith, endsWith, greaterThan, lessThan, greaterOrEqual, lessOrEqual, matches, exists, notExists, isEmpty, isNotEmpty.

Mapeamento de variáveis padrão

Blipflow-ai
{{contactName}}{{contact.name}}
{{contactIdentity}}{{contact.phone}}
{{contactIdentityResolved}}{{contact.phone}}
{{input.content}}{{input}}
{{contact.source}}sem equivalente — referência {{...}} preservada
{{random.guid}}sem equivalente — referência {{...}} preservada
{{calendar.datetime}}sem equivalente — referência {{...}} preservada
{{calendar.date}}sem equivalente — referência {{...}} preservada
{{calendar.time}}sem equivalente — referência {{...}} preservada
{{resource.urlBlipCommands}}sem equivalente — referência {{...}} preservada
{{resource.urlBlip}}sem equivalente — referência {{...}} preservada
{{state.previous.id}}sem equivalente — referência {{...}} preservada
{{state.id}}sem equivalente — referência {{...}} preservada
{{flow.id}}sem equivalente — referência {{...}} preservada

Sem equivalente significa que a entrada existe em buildDefaultMappings() com valor "". Na prática o mapper (applyMappingsToString) não substitui a variável — ela é preservada como {{var}} no texto de saída. O flag interno hasUnmapped é descartado em todos os chamadores (const { result } = …), então uma variável sem equivalente não gera needs-review por si só.


Input de bloco

Campo BlipCampo flow-aiLocalização
input.bypassinput.bypassstate.input ou $contentActions[i].input
input.variableinput.saveInputAs (mapeado)idem
input.expirationneeds-review
ausente{ bypass: true }

O campo input às vezes fica embutido num item de $contentActions em vez de state.input — o importador testa os dois antes de usar o fallback.


Cancelamento e limpeza

Ao cancelar um draft (botão "Cancelar importação" ou browser back confirmado):

  1. Todos os EventDefinition criados durante a importação recebem deletedAt (soft delete).
  2. O ImportDraft é removido do banco.

Os IDs das event definitions criadas são registrados no campo createdEventDefinitionIds do draft na criação, de forma que o cancelamento funcione mesmo após refresh de página.


Triggers de needs-review

SituaçãoCampo afetado
$invalid: true no estado Blipcontent
Script Padrão C/D/E em content actioncontent
Script Padrão A/B não interpretável (falha de parsing)content
mimeType &#123;&#123;Var@mimeType&#125;&#125; sem script correspondentecontent
ProcessHttp com URL contendo msging.net / resource.urlBliphttpCalls
ProcessCommand (sempre — a URL default {{resource.urlBlipCommands}} contém resource.urlBlip)httpCalls
input.expiration presenteinput

Status de resolução por campo (FieldStatus)

Cada campo de um DraftBlock carrega um FieldStatus com um de três valores:

StatusSignificado
autoConvertido automaticamente — não bloqueia a finalização
needs-reviewPrecisa de intervenção manual antes de finalizar
skippedMarcado como "Ignorar" pelo operador — deixa de bloquear a finalização

Na revisão, um campo needs-review pode ser Resolvido (vira auto) ou Ignorado (vira skipped). A finalização só é liberada quando nenhum campo permanece em needs-review — campos skipped contam como resolvidos.


Limitações conhecidas

LimitaçãoImpacto
input.expiration ("H:MM") não convertidoTimeout de input precisa ser configurado manualmente
Scripts C/D/E sem conversão automáticaRevisão manual obrigatória no split-view
Endpoints msging.net / take.netURL precisa ser substituída manualmente
globalActions do JSON raiz ignoradasActions globais de rastreamento não importadas
application/vnd.lime.collection+jsonCarrossel de cards vira placeholder de texto
Redirect sem stub configuradoDescartado silenciosamente; bloco fica sem saída
Imagem / áudio / vídeo inlineSem conversão; action ignorada

Arquivos relevantes

ArquivoPapel
services/flow-ai-core/src/services/import/blip-analyser.tsFase 1: valida JSON, extrai inventário
services/flow-ai-core/src/services/import/blip-script-interpreter.tsClassifica padrões A–E, extrai ContentMessage[]
services/flow-ai-core/src/services/import/blip-variable-mapper.tsAplica biblioteca de mapeamento de variáveis
services/flow-ai-core/src/services/import/blip-converter.tsLoop principal de conversão Blip → DraftBlock
services/flow-ai-core/src/services/import/import-draft.service.tsOrquestra fases, persiste, emite Socket.IO, limpa no cancel
apps/flow-ai-core-ui/app/(studio)/router/[id]/import/blip/page.tsxWizard — passos: upload, inventário, eventos, variáveis, flags+nome (eventos e variáveis só aparecem se houver dados)
apps/flow-ai-core-ui/app/(studio)/router/[id]/import/blip/[draftId]/page.tsxRevisão em 3 colunas: navegação de blocos, JSON Blip cru + avisos, editor inline; cancelamento e interceptação do back do browser
apps/flow-ai-core-ui/app/(studio)/router/[id]/import/blip/[draftId]/[blockId]/page.tsxRevisão de um bloco isolado em 2 colunas (Blip original × editor flow-ai), com Resolver/Ignorar por campo
apps/flow-ai-core-ui/app/(studio)/router/[id]/import/blip/[draftId]/_components/BlockNav, RawBlipPanel, EmbeddedBlockPanel, types
apps/flow-ai-core-ui/app/api/import-drafts/blip/route.ts + blip/analyse/route.tsProxies Next → flow-ai-core para criar draft e analisar JSON
packages/flow-ai-types/src/blip-import.tsContratos: ImportFlags, FieldStatus, DraftBlock, ConversionContext, BlipAnalysisResult

flow-ai — plataforma proprietária de atendimento via WhatsApp