feat(modules): 帮助中心、工单、工作流与多渠道接入
补齐 FAQ、帮助中心、工单、可视化工作流及 Telegram/WhatsApp/Slack 等渠道模块。
This commit is contained in:
1 parent
69a71aabbb
commit
e0baca15a9
225 files changed
+59337
No files matched your search
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
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
|
||||
Reference in new issue
Block a user