The upstream project name is gone from code, assets, docs and build
output: brand strings, domains (replypier.site and its app/api/docs/help
subdomains), env prefixes, image and network names, the widget's public
JS API (window.ReplyPier, replypierId/BaseUrl/Config/Token), CSS tokens
(--rp-*), file and directory names, and the WordPress plugin package.
- widget loader is now webclient/replypier.min.js; both bundles rebuilt
from source (backend/assets/widget.{js,css}, frontend/public/webclient)
- NOTICE, llms.txt, CONTRIBUTING, HELP_CENTER_INFRA and config.dev.js
rewritten rather than string-swapped; the demo config no longer ships
the previous vendor's Firebase/Razorpay/Google keys
- new migration replypier_ai_model_001 adds REPLYPIER to the managed
AIModelType and rewrites pre-rename rows
- local compose project, database, role and volume renamed; LOCAL_DEPLOY
documents carrying the old local data over
292 lines
11 KiB
Python
292 lines
11 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
"""
|
|
|
|
# Evaluates workflow condition nodes.
|
|
#
|
|
# The structure of a condition is fixed before any variable is looked up, and
|
|
# variable values are only ever compared, never parsed. A visitor's answer such
|
|
# as `x" OR "a" === "a` is therefore just text and cannot steer routing.
|
|
|
|
import re
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from app.core.logger import get_logger
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
# Returned by resolve_variable when no value is stored under the name.
|
|
MISSING = object()
|
|
|
|
OPERATORS = frozenset({"===", "!==", ">", "<", ">=", "<=", "includes", "startsWith", "endsWith"})
|
|
_ALIASES = {"==": "===", "!=": "!==", "contains": "includes"}
|
|
_NUMERIC = {
|
|
">": lambda a, b: a > b,
|
|
"<": lambda a, b: a < b,
|
|
">=": lambda a, b: a >= b,
|
|
"<=": lambda a, b: a <= b,
|
|
}
|
|
_NUMBER = re.compile(r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?")
|
|
|
|
|
|
def resolve_variable(name: str, workflow_state: Optional[Dict[str, Any]]) -> Any:
|
|
"""Look up a `{{name}}` value: `node_id.field`, a top-level variable such as
|
|
user_message, or a field from any node (latest wins). MISSING if absent."""
|
|
variables = (workflow_state or {}).get("variables") or {}
|
|
node_variables = {k: v for k, v in variables.items() if isinstance(v, dict)}
|
|
name = name.strip()
|
|
|
|
if "." in name:
|
|
node_id, field_name = (part.strip() for part in name.split(".", 1))
|
|
scoped = node_variables.get(node_id, {})
|
|
return scoped[field_name] if field_name in scoped else MISSING
|
|
|
|
if name in variables and name not in node_variables:
|
|
return variables[name]
|
|
for node_id in reversed(list(node_variables)):
|
|
if name in node_variables[node_id]:
|
|
return node_variables[node_id][name]
|
|
return MISSING
|
|
|
|
|
|
def _as_text(value: Any) -> Optional[str]:
|
|
if value is MISSING or value is None:
|
|
return None
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
return str(value).strip()
|
|
|
|
|
|
def _as_number(text: str) -> Optional[float]:
|
|
return float(text) if _NUMBER.fullmatch(text) else None
|
|
|
|
|
|
def compare(left: Any, operator: str, right: Any) -> bool:
|
|
"""Apply one operator to two values. Case-sensitive; surrounding whitespace
|
|
is ignored. A missing value only satisfies `!==`."""
|
|
operator = _ALIASES.get(operator, operator)
|
|
if operator not in OPERATORS:
|
|
logger.warning(f"Unknown condition operator '{operator}', treating as false")
|
|
return False
|
|
|
|
left_text, right_text = _as_text(left), _as_text(right)
|
|
if operator == "!==":
|
|
return left_text != right_text
|
|
if left_text is None or right_text is None:
|
|
return False
|
|
if operator == "===":
|
|
return left_text == right_text
|
|
if operator == "includes":
|
|
return right_text in left_text
|
|
if operator == "startsWith":
|
|
return left_text.startswith(right_text)
|
|
if operator == "endsWith":
|
|
return left_text.endswith(right_text)
|
|
|
|
left_num, right_num = _as_number(left_text), _as_number(right_text)
|
|
if left_num is None or right_num is None:
|
|
logger.debug(f"Non-numeric '{operator}' comparison is false: {left_text!r} vs {right_text!r}")
|
|
return False
|
|
return _NUMERIC[operator](left_num, right_num)
|
|
|
|
|
|
# --- Builder groups -------------------------------------------------------
|
|
|
|
def _rule_is_complete(rule: Any) -> bool:
|
|
# Mirrors the dashboard, which leaves half-filled rules out of the expression.
|
|
if not isinstance(rule, dict):
|
|
return False
|
|
value = rule.get("value")
|
|
return bool(rule.get("variable")) and bool(rule.get("operator")) and value is not None and str(value).strip() != ""
|
|
|
|
|
|
def _evaluate_rule(rule: Dict[str, Any], workflow_state: Dict[str, Any]) -> bool:
|
|
name = str(rule["variable"]).strip().removeprefix("{{").removesuffix("}}")
|
|
left = resolve_variable(name, workflow_state)
|
|
if left is MISSING:
|
|
logger.warning(f"Condition variable '{name}' has no value")
|
|
return compare(left, str(rule["operator"]), rule["value"])
|
|
|
|
|
|
def evaluate_groups(groups: List[Any], workflow_state: Dict[str, Any]) -> Optional[bool]:
|
|
"""Groups are OR'ed; rules within a group use its logicalOperator (AND by
|
|
default). None when no group holds a complete rule."""
|
|
results = []
|
|
for group in groups:
|
|
if not isinstance(group, dict):
|
|
continue
|
|
rules = [rule for rule in (group.get("rules") or []) if _rule_is_complete(rule)]
|
|
if not rules:
|
|
continue
|
|
outcomes = [_evaluate_rule(rule, workflow_state) for rule in rules]
|
|
any_rule = str(group.get("logicalOperator", "AND")).upper() == "OR"
|
|
results.append(any(outcomes) if any_rule else all(outcomes))
|
|
return any(results) if results else None
|
|
|
|
|
|
# --- Legacy expression strings ---------------------------------------------
|
|
|
|
class ConditionSyntaxError(ValueError):
|
|
pass
|
|
|
|
|
|
_TOKEN = re.compile(r"""
|
|
\s*(?:
|
|
(?P<var>\{\{[^{}]+\}\})
|
|
| (?P<str>"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')
|
|
| (?P<method>\.(?:includes|startsWith|endsWith)\()
|
|
| (?P<op>===|!==|==|!=|>=|<=|>|<|&&|\|\|)
|
|
| (?P<paren>[()])
|
|
| (?P<word>[^\s(){}"'=!<>&|.]+(?:\.(?!(?:includes|startsWith|endsWith)\()[^\s(){}"'=!<>&|.]+)*)
|
|
)\s*""", re.VERBOSE)
|
|
_METHODS = frozenset({"includes", "startsWith", "endsWith"})
|
|
_WORD_KEYWORDS = {"AND": ("and", None), "OR": ("or", None), "contains": ("op", "contains")}
|
|
|
|
Token = Tuple[str, Optional[str]]
|
|
|
|
|
|
def _tokenize(expression: str) -> List[Token]:
|
|
tokens: List[Token] = []
|
|
pos = 0
|
|
while pos < len(expression):
|
|
match = _TOKEN.match(expression, pos)
|
|
if not match or match.end() == pos:
|
|
if expression[pos:].strip():
|
|
raise ConditionSyntaxError(f"unexpected text at position {pos}")
|
|
break
|
|
pos = match.end()
|
|
kind = match.lastgroup
|
|
text = match.group(kind)
|
|
if kind == "var":
|
|
tokens.append(("var", text[2:-2].strip()))
|
|
elif kind == "str":
|
|
tokens.append(("lit", re.sub(r"\\(.)", r"\1", text[1:-1])))
|
|
elif kind == "method":
|
|
tokens.append(("op", text[1:-1]))
|
|
elif kind == "op" and text in ("&&", "||"):
|
|
tokens.append(("and" if text == "&&" else "or", None))
|
|
elif kind == "paren":
|
|
tokens.append((text, None))
|
|
elif kind == "word":
|
|
tokens.append(_WORD_KEYWORDS.get(text, ("lit", text)))
|
|
else:
|
|
tokens.append((kind, text))
|
|
return tokens
|
|
|
|
|
|
class _Parser:
|
|
"""or := and (OR and)* ; and := primary (AND primary)* ;
|
|
primary := '(' or ')' | operand op operand | operand .method( operand )"""
|
|
|
|
def __init__(self, tokens: List[Token]):
|
|
self.tokens = tokens
|
|
self.pos = 0
|
|
|
|
def parse(self):
|
|
if not self.tokens:
|
|
raise ConditionSyntaxError("empty condition")
|
|
node = self._or()
|
|
if self.pos != len(self.tokens):
|
|
raise ConditionSyntaxError("unexpected trailing tokens")
|
|
return node
|
|
|
|
def _peek(self) -> Optional[str]:
|
|
return self.tokens[self.pos][0] if self.pos < len(self.tokens) else None
|
|
|
|
def _take(self, kind: str) -> Token:
|
|
if self._peek() != kind:
|
|
raise ConditionSyntaxError(f"expected {kind}")
|
|
token = self.tokens[self.pos]
|
|
self.pos += 1
|
|
return token
|
|
|
|
def _or(self):
|
|
items = [self._and()]
|
|
while self._peek() == "or":
|
|
self.pos += 1
|
|
items.append(self._and())
|
|
return ("or", items) if len(items) > 1 else items[0]
|
|
|
|
def _and(self):
|
|
items = [self._primary()]
|
|
while self._peek() == "and":
|
|
self.pos += 1
|
|
items.append(self._primary())
|
|
return ("and", items) if len(items) > 1 else items[0]
|
|
|
|
def _primary(self):
|
|
if self._peek() == "(":
|
|
self.pos += 1
|
|
node = self._or()
|
|
self._take(")")
|
|
return node
|
|
left = self._operand()
|
|
_, operator = self._take("op")
|
|
right = self._operand()
|
|
if operator in _METHODS:
|
|
self._take(")") # closes `.includes(`
|
|
return ("cmp", left, operator, right)
|
|
|
|
def _operand(self) -> Token:
|
|
if self._peek() in ("var", "lit"):
|
|
token = self.tokens[self.pos]
|
|
self.pos += 1
|
|
return token
|
|
raise ConditionSyntaxError("expected a value")
|
|
|
|
|
|
def _evaluate_node(node, workflow_state: Dict[str, Any]) -> bool:
|
|
kind = node[0]
|
|
if kind == "or":
|
|
return any(_evaluate_node(item, workflow_state) for item in node[1])
|
|
if kind == "and":
|
|
return all(_evaluate_node(item, workflow_state) for item in node[1])
|
|
_, left, operator, right = node
|
|
return compare(_operand_value(left, workflow_state), operator, _operand_value(right, workflow_state))
|
|
|
|
|
|
def _operand_value(token: Token, workflow_state: Dict[str, Any]) -> Any:
|
|
kind, text = token
|
|
if kind == "lit":
|
|
return text
|
|
value = resolve_variable(text, workflow_state)
|
|
if value is MISSING:
|
|
logger.warning(f"Condition variable '{text}' has no value")
|
|
return value
|
|
|
|
|
|
def evaluate_expression(expression: str, workflow_state: Dict[str, Any]) -> bool:
|
|
"""Evaluate a stored `condition_expression`. Unreadable conditions are false."""
|
|
try:
|
|
tree = _Parser(_tokenize(expression)).parse()
|
|
except (ConditionSyntaxError, RecursionError) as e:
|
|
logger.warning(f"Unreadable condition {expression!r} ({e}), treating as false")
|
|
return False
|
|
return _evaluate_node(tree, workflow_state)
|
|
|
|
|
|
def evaluate_condition_config(config: Dict[str, Any], workflow_state: Dict[str, Any]) -> Optional[bool]:
|
|
"""Evaluate a condition node's config, preferring the builder's groups over
|
|
the generated expression. None when neither holds a usable condition."""
|
|
groups = config.get("condition_groups")
|
|
if isinstance(groups, list):
|
|
result = evaluate_groups(groups, workflow_state)
|
|
if result is not None:
|
|
return result
|
|
expression = config.get("condition_expression")
|
|
if isinstance(expression, str) and expression.strip():
|
|
return evaluate_expression(expression, workflow_state)
|
|
return None
|