Appearance
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
flowdeve estar presente e ser um objeto não vazio. - Os estados especiais
onboardingefallbacksão detectados pelo nome da chave —flow.onboardingeflow.fallback— não por campos internos. - Estados de atendimento humano são detectados pela chave começando com
desk:ou pela presença deForwardToDesknas entering actions.
Formatos de action suportados pelo export Blip
O exportador Blip serializa actions de duas formas diferentes. Ambas são suportadas:
| Formato | Estrutura |
|---|---|
| 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.typedeve ser"button"interactive.action.buttons[]deve ter entre 1 e 3 itens- Cada botão deve ter
reply.idereply.title interactive.body.textdeve estar presente
Para ser convertido como lista (InteractiveListContent):
interactive.typedeve ser"list"interactive.action.sections[]deve ter pelo menos 1 seção com pelo menos 1 rowinteractive.body.textdeve estar presente
Para ser convertido como texto (TextContent):
- A branch WhatsApp não tem campo
interactive - Deve ter campo
textoubodyna 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/plainsem.filter(
Extração do texto — tentada na seguinte ordem de prioridade:
result.message = "…"ouresult.message = '…'ouresult.message = \…``- Qualquer
message = "…"(case-insensitive, capturatextMessagecom M maiúsculo) input.message = "…"- Primeira declaração
let/var/const varName = "texto com 3+ chars" - 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 —\nvira newline de verdade.
Padrões C, D, E — não convertidos automaticamente
| Padrão | Indicador | Resultado |
|---|---|---|
| C | .map( / .forEach( / .reduce( + application/vnd.lime.list+json | executeScript + needs-review |
| D | JSON.parse / JSON.stringify / .map( sem list MIME | executeScript + needs-review |
| E | Nenhum dos anteriores | executeScript + 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 1 — source: "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 2 — source: ["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"):
- Lê
interactive.action.buttons[], limita a 3 - Cada botão:
reply.id→id,reply.title→title interactive.body.text→body
Mapeamento de lista (interactive.type === "list"):
- Lê
interactive.action.sections[], limita rows ao total de 10 - Por row:
id,title,description(opcional) interactive.action.button→buttonText(default"Ver opções")interactive.body.text→body
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 numContentMapno 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 oExecuteScriptem$enteringCustomActionscujooutputVariable === VarNamee interpreta seu padrão. - referência simples (
content: "{{VarName}}"): mesma busca; se o script não for encontrado, preserva a referência literal comoTextContent.body.
Cenários de conversão
Tipos de bloco
| Condição no estado Blip | Tipo gerado | Flags nos warnings |
|---|---|---|
Chave começa com desk: | humanAttendance | — |
Tem ForwardToDesk nas entering actions | humanAttendance | — |
Chave é onboarding | standard | onboarding:true → onboardingBlockId |
Chave é fallback | standard | fallback:true → defaultExceptionBlockId |
$invalid: true | qualquer | needs-review em content |
| demais | standard | — |
Actions (entering / leaving)
| Action Blip | Action flow-ai | Status |
|---|---|---|
SetVariable | setContextVariable | auto |
ExecuteScript padrão A/B | descartado (flag removeInterpreted) | auto |
ExecuteScript padrão C/D/E | executeScript | auto (se scripts.logic ligado) |
ProcessHttp | httpCall | auto; needs-review se URL contém msging.net / resource.urlBlip (take.net gera apenas warning, não needs-review) |
ProcessCommand | httpCall | needs-review sempre |
MergeContact | N × setContactVariable (campos + entradas em extras) | auto |
TrackEvent | recordEvent | auto (requer mapeamento na Fase 2) |
ForwardToDesk | descartada | auto (implícita no humanAttendance) |
Redirect | redirectToBot | auto (requer stubFlowId configurado) |
| tipo desconhecido | descartada + warning | — |
Conteúdo inline (SendRawMessage / SendMessage)
| mimeType | Conversão |
|---|---|
ausente ou text/plain | TextContent |
application/vnd.lime.select+json | ≤ 3 opções → InteractiveButtonsContent; senão → TextContent |
application/vnd.lime.collection+json | TextContent com placeholder |
application/vnd.lime.chatstate+json | ignorado — converte em typingMs na mensagem seguinte |
| qualquer outro | TextContent (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
| Blip | flow-ai |
|---|---|
$conditionOutputs[].stateId | outputConditions[].target.blockId (via blockIdMap) |
$defaultOutput.stateId | defaultOutput.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
| Blip | flow-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 internohasUnmappedé descartado em todos os chamadores (const { result } = …), então uma variável sem equivalente não geraneeds-reviewpor si só.
Input de bloco
| Campo Blip | Campo flow-ai | Localização |
|---|---|---|
input.bypass | input.bypass | state.input ou $contentActions[i].input |
input.variable | input.saveInputAs (mapeado) | idem |
input.expiration | — | needs-review |
| ausente | { bypass: true } | — |
O campo
inputàs vezes fica embutido num item de$contentActionsem vez destate.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):
- Todos os
EventDefinitioncriados durante a importação recebemdeletedAt(soft delete). - 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ção | Campo afetado |
|---|---|
$invalid: true no estado Blip | content |
| Script Padrão C/D/E em content action | content |
| Script Padrão A/B não interpretável (falha de parsing) | content |
mimeType {{Var@mimeType}} sem script correspondente | content |
ProcessHttp com URL contendo msging.net / resource.urlBlip | httpCalls |
ProcessCommand (sempre — a URL default {{resource.urlBlipCommands}} contém resource.urlBlip) | httpCalls |
input.expiration presente | input |
Status de resolução por campo (FieldStatus)
Cada campo de um DraftBlock carrega um FieldStatus com um de três valores:
| Status | Significado |
|---|---|
auto | Convertido automaticamente — não bloqueia a finalização |
needs-review | Precisa de intervenção manual antes de finalizar |
skipped | Marcado 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ção | Impacto |
|---|---|
input.expiration ("H:MM") não convertido | Timeout de input precisa ser configurado manualmente |
| Scripts C/D/E sem conversão automática | Revisão manual obrigatória no split-view |
Endpoints msging.net / take.net | URL precisa ser substituída manualmente |
globalActions do JSON raiz ignoradas | Actions globais de rastreamento não importadas |
application/vnd.lime.collection+json | Carrossel de cards vira placeholder de texto |
Redirect sem stub configurado | Descartado silenciosamente; bloco fica sem saída |
| Imagem / áudio / vídeo inline | Sem conversão; action ignorada |
Arquivos relevantes
| Arquivo | Papel |
|---|---|
services/flow-ai-core/src/services/import/blip-analyser.ts | Fase 1: valida JSON, extrai inventário |
services/flow-ai-core/src/services/import/blip-script-interpreter.ts | Classifica padrões A–E, extrai ContentMessage[] |
services/flow-ai-core/src/services/import/blip-variable-mapper.ts | Aplica biblioteca de mapeamento de variáveis |
services/flow-ai-core/src/services/import/blip-converter.ts | Loop principal de conversão Blip → DraftBlock |
services/flow-ai-core/src/services/import/import-draft.service.ts | Orquestra fases, persiste, emite Socket.IO, limpa no cancel |
apps/flow-ai-core-ui/app/(studio)/router/[id]/import/blip/page.tsx | Wizard — 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.tsx | Revisã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.tsx | Revisã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.ts | Proxies Next → flow-ai-core para criar draft e analisar JSON |
packages/flow-ai-types/src/blip-import.ts | Contratos: ImportFlags, FieldStatus, DraftBlock, ConversionContext, BlipAnalysisResult |