// Port of tai.py:9302-9985: PlexerController family, plexer_show spec validation, PlexerFlow. #include "mplex_plexer_flow.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include "mplex_common.h" #include "mplex_py.h" namespace detz::mplex_flow { namespace py = detz::mplex::py; using detz::mplex::to_ustr; using detz::mplex::UStr; const std::vector PLEXER_FLOW_TYPES = {"choice", "text", "info"}; const std::vector PLEXER_FLOW_KINDS = {"message", "choice", "text"}; namespace { // ---- Python value helpers --------------------------------------------------------------- std::string repr_json(const Json& v) { switch (v.kind()) { case Json::Kind::Null: return "None"; case Json::Kind::Bool: return v.as_bool() ? "True" : "False"; case Json::Kind::Int: return std::to_string(v.as_int()); case Json::Kind::Float: return py::format_float(v.as_float()); case Json::Kind::String: return py::repr(v.as_string()); case Json::Kind::Array: { std::string out = "["; bool first = true; for (const auto& item : v.as_array()) { if (!first) out += ", "; first = false; out += repr_json(item); } return out + "]"; } case Json::Kind::Object: { std::string out = "{"; bool first = true; for (const auto& item : v.as_object()) { if (!first) out += ", "; first = false; out += py::repr(item.first) + ": " + repr_json(item.second); } return out + "}"; } } return "None"; } // str(value) std::string str_json(const Json& v) { return v.is_string() ? v.as_string() : repr_json(v); } // "%d" in error texts std::string d(long long n) { return std::to_string(n); } bool is_scalar(const Json& v) { return v.is_string() || v.is_number() || v.is_bool() || v.is_null(); } // int(value) with Python's TypeError/ValueError turned into nullopt. std::optional py_int(const Json& v) { switch (v.kind()) { case Json::Kind::Bool: return v.as_bool() ? 1 : 0; case Json::Kind::Int: return v.as_int(); case Json::Kind::Float: { double f = v.as_float(); if (f != f || f > 9e18 || f < -9e18) return std::nullopt; return static_cast(f); } case Json::Kind::String: try { return py::to_int(v.as_string()); } catch (const std::exception&) { return std::nullopt; } default: return std::nullopt; } } const Json null_json; const Json& jget(const Json& obj, std::string_view key) { const Json* hit = obj.get(std::string(key)); return hit ? *hit : null_json; } Json jget_or(const Json& obj, const std::string& key, const Json& fallback) { const Json* hit = obj.get(key); return hit ? *hit : fallback; } bool in_list(const std::vector& list, const std::string& value) { return std::find(list.begin(), list.end(), value) != list.end(); } std::string errno_text(int err) { return "[Errno " + std::to_string(err) + "] " + std::strerror(err); } std::string cap300(const std::string& text) { return py::slice(text, 0, 300); } std::string universal_newlines(const std::string& raw) { std::string out; out.reserve(raw.size()); for (size_t i = 0; i < raw.size(); ++i) { if (raw[i] == '\r') { out.push_back('\n'); if (i + 1 < raw.size() && raw[i + 1] == '\n') ++i; } else { out.push_back(raw[i]); } } return out; } std::string decode_replace(const std::string& raw) { return detz::mplex::to_utf8(to_ustr(raw)); } // "text" % shlex-quoted arg, for the reader fragments: handles %% and a single %s. std::string format_s(const std::string& fmt, const std::string& arg) { std::string out; for (size_t i = 0; i < fmt.size(); ++i) { if (fmt[i] == '%' && i + 1 < fmt.size()) { if (fmt[i + 1] == '%') { out.push_back('%'); ++i; continue; } if (fmt[i + 1] == 's') { out += arg; ++i; continue; } } out.push_back(fmt[i]); } return out; } std::string temp_dir() { for (const char* name : {"TMPDIR", "TEMP", "TMP"}) { const char* v = std::getenv(name); if (v && *v) return v; } return "/tmp"; } // tempfile.mkstemp(prefix="tai-flow-", suffix=...): returns (fd, path); fd -1 on failure. std::pair make_temp(const std::string& suffix) { std::string pattern = temp_dir() + "/tai-flow-XXXXXX" + suffix; std::vector buf(pattern.begin(), pattern.end()); buf.push_back('\0'); int fd = ::mkstemps(buf.data(), static_cast(suffix.size())); return {fd, std::string(buf.data())}; } } // namespace std::string plexer_dir(const std::string& home) { std::string path = home + "/" + PLEXER_DIR_NAME; std::string partial; size_t pos = 0; while (pos <= path.size()) { size_t next = path.find('/', pos); if (next == std::string::npos) next = path.size(); partial = path.substr(0, next); if (!partial.empty()) ::mkdir(partial.c_str(), 0777); pos = next + 1; } ::chmod(path.c_str(), 0700); return path; } // ---- PlexerController --------------------------------------------------------------------- void PlexerController::type_text(const std::string& text, double delay, bool enter) { UStr chars = to_ustr(text); for (wchar_t ch : chars) { send_keys(detz::mplex::to_utf8(ch)); ::usleep(static_cast(delay * 1e6)); } if (enter) send_keys("", true); } // ---- NativePlexerController ----------------------------------------------------------------- NativePlexerController::NativePlexerController(const std::string& socket_path_in, const std::optional& pane_in) : socket_path(socket_path_in), pane(pane_in) { kind = "native"; } Json NativePlexerController::call(const Json& payload_in) { Json payload = payload_in; if (pane && !pane->empty()) payload.set("pane", Json::string(*pane)); int sock = ::socket(AF_UNIX, SOCK_STREAM, 0); struct timeval tv {10, 0}; if (sock >= 0) { ::setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof tv); ::setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof tv); } struct sockaddr_un addr {}; addr.sun_family = AF_UNIX; int err = 0; if (sock < 0) { err = errno; } else if (socket_path.size() >= sizeof(addr.sun_path)) { err = ENAMETOOLONG; } else { std::memcpy(addr.sun_path, socket_path.c_str(), socket_path.size() + 1); if (::connect(sock, reinterpret_cast(&addr), sizeof addr) != 0) err = errno; } if (err) { if (sock >= 0) ::close(sock); throw PlexerError("cannot reach native plexer backend: " + cap300(err == EAGAIN ? "timed out" : errno_text(err))); } std::string chunks; try { std::string line = payload.dumps() + "\n"; size_t done = 0; while (done < line.size()) { ssize_t n = ::send(sock, line.data() + done, line.size() - done, MSG_NOSIGNAL); if (n < 0) { if (errno == EINTR) continue; int e = errno; throw PlexerError("native plexer backend read/write failed: " + cap300(e == EAGAIN ? "timed out" : errno_text(e))); } done += static_cast(n); } for (;;) { char chunk[65536]; ssize_t n = ::recv(sock, chunk, sizeof chunk, 0); if (n < 0) { if (errno == EINTR) continue; int e = errno; throw PlexerError("native plexer backend read/write failed: " + cap300(e == EAGAIN ? "timed out" : errno_text(e))); } if (n == 0) break; chunks.append(chunk, static_cast(n)); if (chunk[n - 1] == '\n') break; } } catch (...) { ::close(sock); throw; } ::close(sock); std::string raw = py::strip(decode_replace(chunks)); Json reply = Json::object(); if (!raw.empty()) { try { reply = Json::loads(raw); } catch (const std::exception&) { throw PlexerError("native plexer backend sent an unparseable reply"); } } if (!reply.is_object()) throw PlexerError("native plexer backend refused the command"); const Json* ok = reply.get("ok"); if (!(ok && ok->truthy())) { const Json* error = reply.get("error"); if (error && error->truthy()) throw PlexerError(str_json(*error)); throw PlexerError("native plexer backend refused the command"); } return jget(reply, "data"); } Json NativePlexerController::send_keys(const std::string& text, bool enter) { Json p = Json::object(); p.set("cmd", Json::string("send")); p.set("text", Json::string(text)); p.set("enter", Json::boolean(enter)); return call(p); } Json NativePlexerController::read_pane(int lines) { Json p = Json::object(); p.set("cmd", Json::string("read")); p.set("lines", Json::integer(lines)); return call(p); } Json NativePlexerController::status() { Json p = Json::object(); p.set("cmd", Json::string("status")); return call(p); } Json NativePlexerController::ask(const Json& screen, double timeout) { Json show = Json::object(); show.set("cmd", Json::string("show")); show.set("screen", screen); show.set("timeout", Json::number(timeout)); Json handle = call(show); if (!handle.is_object() || !handle.has("id")) throw PlexerError("native plexer backend sent no flow id"); Json flow_id = jget(handle, "id"); double deadline = py::monotonic() + std::max(1.0, timeout); for (;;) { Json q = Json::object(); q.set("cmd", Json::string("result")); q.set("id", flow_id); Json reply = call(q); const Json* done = reply.is_object() ? reply.get("done") : nullptr; if (done && done->truthy()) { const Json* status = reply.get("status"); std::string st = (status && status->is_string()) ? status->as_string() : ""; if (st == "ok") { Json out = Json::object(); out.set("status", Json::string("ok")); out.set("value", jget(reply, "value")); return out; } if (st == "cancelled" || st == "timeout") { Json out = Json::object(); out.set("status", Json::string(st)); return out; } std::string shown = (status && status->truthy()) ? str_json(*status) : "unknown"; throw PlexerError("flow " + str_json(flow_id) + " ended: " + shown); } if (py::monotonic() >= deadline) { try { Json c = Json::object(); c.set("cmd", Json::string("cancel")); c.set("id", flow_id); call(c); } catch (const PlexerError&) { } Json out = Json::object(); out.set("status", Json::string("timeout")); return out; } ::usleep(static_cast(PLEXER_FLOW_POLL * 1e6)); } } // ---- TmuxPlexerController --------------------------------------------------------------------- TmuxPlexerController::TmuxPlexerController(const std::string& pane_in) : pane(pane_in) { kind = "tmux"; } std::string TmuxPlexerController::tmux(const std::vector& args) { std::vector argv = {"tmux"}; argv.insert(argv.end(), args.begin(), args.end()); py::RunResult done; try { done = py::run_argv(argv, true, true, 15); } catch (const std::exception& exc) { throw PlexerError("tmux call failed: " + cap300(exc.what())); } if (done.timed_out) { std::string cmd = "["; for (size_t i = 0; i < argv.size(); ++i) cmd += (i ? ", " : "") + py::repr(argv[i]); cmd += "]"; throw PlexerError("tmux call failed: " + cap300("Command '" + cmd + "' timed out after 15 seconds")); } if (done.returncode != 0) throw PlexerError("tmux error: " + py::slice(py::strip(universal_newlines(decode_replace(done.err))), 0, 300)); return universal_newlines(decode_replace(done.out)); } Json TmuxPlexerController::send_keys(const std::string& text, bool enter) { if (!text.empty()) tmux({"send-keys", "-t", pane, "-l", text}); if (enter) tmux({"send-keys", "-t", pane, "Enter"}); return Json::boolean(true); } Json TmuxPlexerController::read_pane(int lines) { return Json::string(tmux({"capture-pane", "-p", "-t", pane, "-S", "-" + std::to_string(std::max(1, lines))})); } Json TmuxPlexerController::status() { std::string info = tmux({"list-panes", "-t", pane, "-F", "#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}"}); Json out = Json::object(); out.set("backend", Json::string("tmux")); out.set("pane", Json::string(pane)); out.set("info", Json::string(py::strip(info))); return out; } Json TmuxPlexerController::ask(const Json& screen, double timeout) { const Json& kind_json = jget(screen, "kind"); std::string k = kind_json.is_string() ? kind_json.as_string() : ""; if (kind_json.is_string()) { if (k == "choice") return ask_choice(screen, timeout); if (k == "text") return ask_text(screen, timeout); if (k == "message") return ask_message(screen, timeout); } throw PlexerError("unknown screen kind " + repr_json(kind_json)); } std::string TmuxPlexerController::ask_result_path() { auto made = make_temp(".answer"); if (made.first >= 0) ::close(made.first); ::unlink(made.second.c_str()); return made.second; } std::optional TmuxPlexerController::await_answer(const std::string& path, double budget) { double deadline = py::monotonic() + std::max(0.0, budget); for (;;) { std::ifstream in(path, std::ios::binary); if (in) { std::stringstream ss; ss << in.rdbuf(); return decode_replace(ss.str()); } if (py::monotonic() >= deadline) return std::nullopt; ::usleep(50000); } } void TmuxPlexerController::ask_dismiss() { try { py::run_argv({"tmux", "display-popup", "-C"}, true, true, 5); } catch (const std::exception&) { } } TmuxPlexerController::RunOutcome TmuxPlexerController::ask_run(const std::vector& argv, const std::string& path, double timeout) { std::vector full = {"tmux"}; full.insert(full.end(), argv.begin(), argv.end()); py::RunResult done; try { done = py::run_argv(full, true, true, std::max(1.0, timeout)); } catch (const std::exception& exc) { throw PlexerError("tmux call failed: " + cap300(exc.what())); } RunOutcome out; if (done.timed_out) { ask_dismiss(); out.timed_out = true; out.content = "timeout"; return out; } std::string err = py::strip(universal_newlines(decode_replace(done.err))); if (done.returncode != 0 && !err.empty()) throw PlexerError("tmux " + argv[0] + " failed: " + py::slice(err, 0, 300)); if (done.returncode != 0) return out; out.content = await_answer(path); return out; } Json TmuxPlexerController::ask_choice(const Json& screen, double timeout) { const Json& opts_json = jget(screen, "options"); std::vector options; if (opts_json.truthy() && opts_json.is_array()) for (const auto& o : opts_json.as_array()) options.push_back(str_json(o)); std::string path = ask_result_path(); RunOutcome raw; try { const Json& title = jget(screen, "title"); std::vector argv = {"display-menu", "-T", title.truthy() ? str_json(title) : "tai"}; static const std::string keys = "123456789abcdefghijklmnopqrstuvwxyz"; for (size_t index = 0; index < options.size(); ++index) { if (index >= keys.size()) throw PlexerError("string index out of range"); argv.push_back(options[index]); argv.push_back(std::string(1, keys[index])); argv.push_back("run-shell \"printf '%s' '" + std::to_string(index) + "' > " + py::shlex_quote(path) + "\""); } raw = ask_run(argv, path, timeout); } catch (...) { ::unlink(path.c_str()); throw; } ::unlink(path.c_str()); Json out = Json::object(); if (raw.content && *raw.content == "timeout") { out.set("status", Json::string("timeout")); return out; } std::string stripped = raw.content ? py::strip(*raw.content) : ""; if (!raw.content || !py::all_digits(to_ustr(stripped))) { out.set("status", Json::string("cancelled")); return out; } long long index = py::to_int(stripped); if (index >= 0 && index < static_cast(options.size())) { out.set("status", Json::string("ok")); out.set("value", Json::integer(index)); return out; } out.set("status", Json::string("cancelled")); return out; } TmuxPlexerController::RunOutcome TmuxPlexerController::ask_popup(const std::string& title, const std::string& body, const std::string& reader, double timeout) { auto made = make_temp(".prompt"); if (made.first < 0) throw PlexerError("cannot stage prompt text: " + errno_text(errno)); std::string prompt_path = made.second; { size_t done = 0; while (done < body.size()) { ssize_t n = ::write(made.first, body.data() + done, body.size() - done); if (n < 0) { if (errno == EINTR) continue; std::string text = errno_text(errno); ::close(made.first); throw PlexerError("cannot stage prompt text: " + cap300(text)); } done += static_cast(n); } ::close(made.first); } std::string path = ask_result_path(); RunOutcome raw; try { std::string command = "cat " + py::shlex_quote(prompt_path) + "; " + format_s(reader, py::shlex_quote(path)); raw = ask_run({"display-popup", "-w", "62", "-h", "10", "-T", title.empty() ? "tai" : title, "-E", command}, path, timeout); } catch (...) { ::unlink(prompt_path.c_str()); ::unlink(path.c_str()); throw; } ::unlink(prompt_path.c_str()); ::unlink(path.c_str()); return raw; } Json TmuxPlexerController::ask_text(const Json& screen, double timeout) { const Json& text = jget(screen, "text"); std::string body = (text.truthy() ? str_json(text) : std::string()) + "\n\n> "; const Json& title = jget(screen, "title"); RunOutcome raw = ask_popup(title.truthy() ? str_json(title) : "", body, "read -r TAI_V && printf '%%s' \"$TAI_V\" > %s", timeout); Json out = Json::object(); if (raw.content && *raw.content == "timeout") { out.set("status", Json::string("timeout")); return out; } if (!raw.content) { out.set("status", Json::string("cancelled")); return out; } out.set("status", Json::string("ok")); out.set("value", Json::string(py::slice(*raw.content, 0, PLEXER_FLOW_ANSWER_MAX))); return out; } Json TmuxPlexerController::ask_message(const Json& screen, double timeout) { const Json& text = jget(screen, "text"); std::string body = (text.truthy() ? str_json(text) : std::string()) + "\n\n[Enter] "; const Json& title = jget(screen, "title"); RunOutcome raw = ask_popup(title.truthy() ? str_json(title) : "", body, "read -r _ && printf ok > %s", timeout); Json out = Json::object(); if (raw.content && *raw.content == "timeout") { out.set("status", Json::string("timeout")); return out; } if (!raw.content) { out.set("status", Json::string("cancelled")); return out; } out.set("status", Json::string("ok")); out.set("value", Json::boolean(true)); return out; } std::unique_ptr PlexerControllerFactory::detect() { const char* socket_path = std::getenv(PLEXER_SOCKET_ENV); if (socket_path && *socket_path) { const char* pane = std::getenv(PLEXER_PANE_ENV); return std::make_unique(socket_path, pane ? std::optional(pane) : std::nullopt); } const char* pane = std::getenv("TMUX_PANE"); if (pane && *pane) return std::make_unique(pane); return nullptr; } // ---- validation -------------------------------------------------------------------------------- std::pair validate_flow_id(const Json& raw, const std::string& what) { std::string text = py::strip(raw.truthy() ? str_json(raw) : std::string()); if (text.empty()) return {"", what + " id must be a non-empty string"}; if (py::len(text) > static_cast(PLEXER_FLOW_ID_MAX)) return {"", what + " id " + py::repr(py::slice(text, 0, 72)) + " is longer than " + d(PLEXER_FLOW_ID_MAX) + " chars"}; for (wchar_t ch : to_ustr(text)) if (py::isspace(ch)) return {"", what + " id " + py::repr(text) + " must not contain whitespace"}; return {text, ""}; } std::pair validate_flow_text(const Json& raw, const std::string& what, size_t limit) { if (!raw.is_string()) return {"", what + " must be a string"}; if (py::len(raw.as_string()) > limit) return {"", what + " is longer than " + d(static_cast(limit)) + " chars"}; return {raw.as_string(), ""}; } std::pair validate_flow_options(const Json& raw, const std::string& what) { if (!raw.is_array() || raw.as_array().empty()) return {Json::array(), what + " needs 1 to " + d(PLEXER_FLOW_OPTIONS_MAX) + " options"}; if (raw.as_array().size() > static_cast(PLEXER_FLOW_OPTIONS_MAX)) return {Json::array(), what + " has " + d(static_cast(raw.as_array().size())) + " options, at most " + d(PLEXER_FLOW_OPTIONS_MAX)}; Json options = Json::array(); long long pos = 0; for (const auto& item : raw.as_array()) { Json label, value; if (item.is_string()) { label = item; value = item; } else if (item.is_object()) { label = jget(item, "label"); value = jget_or(item, "value", jget(item, "label")); } else { return {Json::array(), what + " option " + d(pos + 1) + " must be a string or {label, value}"}; } if (!label.is_string() || py::strip(label.as_string()).empty()) return {Json::array(), what + " option " + d(pos + 1) + " needs a non-empty label"}; if (py::len(label.as_string()) > static_cast(PLEXER_FLOW_LABEL_MAX)) return {Json::array(), what + " option " + d(pos + 1) + " label is longer than " + d(PLEXER_FLOW_LABEL_MAX) + " chars"}; if (!is_scalar(value)) return {Json::array(), what + " option " + d(pos + 1) + " value must be a JSON scalar"}; Json entry = Json::object(); entry.set("label", label); entry.set("value", value); options.push(entry); ++pos; } return {options, ""}; } std::pair validate_flow_condition(const Json& raw, const std::string& what, const std::vector& known) { if (raw.is_null()) return {Json::object(), ""}; if (!raw.is_object() || raw.as_object().empty()) return {Json::object(), what + " show_if must be a non-empty {field id: expected} object"}; Json clauses = Json::object(); for (const auto& entry : raw.as_object()) { const std::string& key = entry.first; const Json& expected = entry.second; if (!in_list(known, key)) return {Json::object(), what + " show_if refers to unknown field " + py::repr(key)}; Json accepted = expected.is_array() ? expected : Json::array({expected}); if (accepted.as_array().empty()) return {Json::object(), what + " show_if for " + py::repr(key) + " needs at least one expected value"}; for (const auto& value : accepted.as_array()) if (!is_scalar(value)) return {Json::object(), what + " show_if for " + py::repr(key) + " must hold JSON scalars"}; clauses.set(key, accepted); } return {clauses, ""}; } namespace { using Fail = std::pair; Fail fail(const std::string& reason) { return {Json(), reason}; } } // namespace std::pair validate_flow_spec(const Json& spec) { if (!spec.is_object()) return fail("spec must be a JSON object"); Json version = jget_or(spec, "v", Json::integer(PLEXER_FLOW_VERSION)); if (!version.equals(Json::integer(PLEXER_FLOW_VERSION))) return fail("spec v must be " + d(PLEXER_FLOW_VERSION) + ", got " + repr_json(version)); auto [title, error] = validate_flow_text(jget_or(spec, "title", Json::string("")), "title", PLEXER_FLOW_TITLE_MAX); if (!error.empty()) return fail(error); auto timeout_opt = py_int(jget_or(spec, "timeout", Json::integer(PLEXER_FLOW_TIMEOUT_DEFAULT))); if (!timeout_opt) return fail("timeout must be an integer number of seconds"); long long timeout = *timeout_opt; if (!(PLEXER_FLOW_TIMEOUT_MIN <= timeout && timeout <= PLEXER_FLOW_TIMEOUT_MAX)) return fail("timeout must be " + d(PLEXER_FLOW_TIMEOUT_MIN) + " to " + d(PLEXER_FLOW_TIMEOUT_MAX) + " seconds"); const Json& raw_screens = jget(spec, "screens"); if (!raw_screens.is_array() || raw_screens.as_array().empty()) return fail("screens must be a non-empty list"); if (raw_screens.as_array().size() > static_cast(PLEXER_FLOW_SCREENS_MAX)) return fail(d(static_cast(raw_screens.as_array().size())) + " screens, at most " + d(PLEXER_FLOW_SCREENS_MAX)); Json screens = Json::array(); std::vector screen_ids, field_ids; long long pos = 0; for (const auto& item : raw_screens.as_array()) { std::string what = "screen " + d(pos + 1); ++pos; if (!item.is_object()) return fail(what + " must be an object"); auto [ident, e1] = validate_flow_id(jget(item, "id"), what); if (!e1.empty()) return fail(e1); if (in_list(screen_ids, ident)) return fail("duplicate screen id " + py::repr(ident)); screen_ids.push_back(ident); auto [title_s, e2] = validate_flow_text(jget_or(item, "title", Json::string("")), ident + " title", PLEXER_FLOW_TITLE_MAX); if (!e2.empty()) return fail(e2); auto [body, e3] = validate_flow_text(jget_or(item, "body", Json::string("")), ident + " body", PLEXER_FLOW_TEXT_MAX); if (!e3.empty()) return fail(e3); Json raw_fields = jget_or(item, "fields", Json::array()); if (!raw_fields.is_array()) return fail(ident + " fields must be a list"); if (raw_fields.as_array().size() > static_cast(PLEXER_FLOW_FIELDS_MAX)) return fail(ident + " has " + d(static_cast(raw_fields.as_array().size())) + " fields, at most " + d(PLEXER_FLOW_FIELDS_MAX)); Json fields = Json::array(); long long fpos = 0; for (const auto& field : raw_fields.as_array()) { std::string fwhat = ident + " field " + d(fpos + 1); ++fpos; if (!field.is_object()) return fail(fwhat + " must be an object"); Json kind = jget(field, "type"); if (!(kind.is_string() && in_list(PLEXER_FLOW_TYPES, kind.as_string()))) { std::string listed; for (size_t i = 0; i < PLEXER_FLOW_TYPES.size(); ++i) listed += (i ? "/" : "") + PLEXER_FLOW_TYPES[i]; return fail(fwhat + " type must be one of " + listed + ", got " + repr_json(kind)); } auto [fid, e4] = validate_flow_id(jget(field, "id"), fwhat); if (!e4.empty()) return fail(e4); if (in_list(field_ids, fid)) return fail("duplicate field id " + py::repr(fid)); field_ids.push_back(fid); auto [label, e5] = validate_flow_text(jget_or(field, "label", Json::string("")), fid + " label", PLEXER_FLOW_LABEL_MAX); if (!e5.empty()) return fail(e5); Json entry = Json::object(); entry.set("id", Json::string(fid)); entry.set("type", kind); entry.set("label", Json::string(label)); entry.set("show_if", jget(field, "show_if")); if (kind.as_string() == "choice") { auto [options, e6] = validate_flow_options(jget(field, "options"), fid); if (!e6.empty()) return fail(e6); entry.set("options", options); auto def = py_int(jget_or(field, "default", Json::integer(0))); if (!def) return fail(fid + " default must be an option index"); long long count = static_cast(options.as_array().size()); if (!(0 <= *def && *def < count)) return fail(fid + " default " + d(*def) + " is outside its " + d(count) + " options"); entry.set("default", Json::integer(*def)); } else if (kind.as_string() == "text") { auto [initial, e7] = validate_flow_text(jget_or(field, "initial", Json::string("")), fid + " initial", PLEXER_FLOW_ANSWER_MAX); if (!e7.empty()) return fail(e7); entry.set("initial", Json::string(initial)); } else { auto [text, e8] = validate_flow_text(jget_or(field, "text", Json::string("")), fid + " text", PLEXER_FLOW_TEXT_MAX); if (!e8.empty()) return fail(e8); entry.set("text", Json::string(text)); } fields.push(entry); } Json raw_actions = jget_or(item, "actions", Json::array()); if (!raw_actions.is_array()) return fail(ident + " actions must be a list"); if (raw_actions.as_array().size() > static_cast(PLEXER_FLOW_ACTIONS_MAX)) return fail(ident + " has " + d(static_cast(raw_actions.as_array().size())) + " actions, at most " + d(PLEXER_FLOW_ACTIONS_MAX)); Json actions = Json::array(); std::vector action_ids; long long apos = 0; for (const auto& action : raw_actions.as_array()) { std::string awhat = ident + " action " + d(apos + 1); ++apos; if (!action.is_object()) return fail(awhat + " must be an object"); auto [aid, e9] = validate_flow_id(jget(action, "id"), awhat); if (!e9.empty()) return fail(e9); if (in_list(action_ids, aid)) return fail(ident + " has a duplicate action id " + py::repr(aid)); action_ids.push_back(aid); auto [label, e10] = validate_flow_text(jget_or(action, "label", Json::string("")), aid + " label", PLEXER_FLOW_LABEL_MAX); if (!e10.empty()) return fail(e10); if (py::strip(label).empty()) return fail(aid + " needs a non-empty label"); Json entry = Json::object(); entry.set("id", Json::string(aid)); entry.set("label", Json::string(label)); actions.push(entry); } if (py::strip(body).empty() && fields.as_array().empty() && actions.as_array().empty()) return fail(ident + " has nothing to show (no body, fields or actions)"); Json screen = Json::object(); screen.set("id", Json::string(ident)); screen.set("title", Json::string(title_s)); screen.set("body", Json::string(body)); screen.set("fields", fields); screen.set("actions", actions); screen.set("next", jget(item, "next")); screen.set("show_if", jget(item, "show_if")); screens.push(screen); } std::vector known_fields; for (const auto& screen : screens.as_array()) for (const auto& field : jget(screen, "fields").as_array()) { const std::string& t = jget(field, "type").as_string(); if (t == "choice" || t == "text") known_fields.push_back(jget(field, "id").as_string()); } for (auto& screen : screens.as_array()) { for (auto& field : screen.get("fields")->as_array()) { auto [clauses, err] = validate_flow_condition(jget(field, "show_if"), jget(field, "id").as_string(), known_fields); if (!err.empty()) return fail(err); field.set("show_if", clauses); } const std::string sid = jget(screen, "id").as_string(); { auto [clauses, err] = validate_flow_condition(jget(screen, "show_if"), sid, known_fields); if (!err.empty()) return fail(err); screen.set("show_if", clauses); } Json target = jget(screen, "next"); auto known_target = [&](const Json& dest) { return dest.is_string() && (dest.as_string() == "$done" || dest.as_string() == "$cancel" || in_list(screen_ids, dest.as_string())); }; if (target.is_null()) { screen.set("next", Json()); } else if (target.is_string()) { if (!known_target(target)) return fail(sid + " next refers to unknown screen " + repr_json(target)); screen.set("next", target); } else if (target.is_object()) { if (jget(screen, "actions").as_array().empty()) return fail(sid + " next is a map but the screen has no actions"); for (const auto& entry : target.as_object()) { bool found = false; for (const auto& action : jget(screen, "actions").as_array()) if (jget(action, "id").as_string() == entry.first) found = true; if (!found) return fail(sid + " next maps unknown action " + py::repr(entry.first)); if (!known_target(entry.second)) return fail(sid + " next for " + py::repr(entry.first) + " refers to unknown screen " + repr_json(entry.second)); } screen.set("next", target); } else { return fail(sid + " next must be a screen id or an {action: screen} map"); } } Json start = jget_or(spec, "start", jget(screens.as_array()[0], "id")); if (!(start.is_string() && in_list(screen_ids, start.as_string()))) return fail("start refers to unknown screen " + repr_json(start)); Json app = Json::object(); app.set("title", Json::string(title)); app.set("timeout", Json::integer(timeout)); app.set("start", start); app.set("screens", screens); return {app, ""}; } // ---- PlexerFlow ----------------------------------------------------------------------------------- PlexerFlow::PlexerFlow(const Json& app_in) : app(app_in) { for (const auto& screen : app.get("screens")->as_array()) { by_id[jget(screen, "id").as_string()] = screen; order.push_back(jget(screen, "id").as_string()); } } bool PlexerFlow::condition_holds(const Json& clauses, const Json& answers) { if (!clauses.is_object()) return true; for (const auto& entry : clauses.as_object()) { const Json* have = answers.get(entry.first); if (!have) return false; bool hit = false; for (const auto& accepted : entry.second.as_array()) if (have->equals(accepted)) hit = true; if (!hit) return false; } return true; } std::string PlexerFlow::follow_up(const Json& screen) { const Json& target = jget(screen, "next"); if (target.is_string()) return target.as_string(); size_t pos = 0; const std::string& id = jget(screen, "id").as_string(); for (size_t i = 0; i < order.size(); ++i) if (order[i] == id) pos = i; if (pos + 1 < order.size()) return order[pos + 1]; return "$done"; } const Json* PlexerFlow::first_askable(const Json& screen) { for (const auto& field : jget(screen, "fields").as_array()) { const std::string& t = jget(field, "type").as_string(); if ((t == "choice" || t == "text") && condition_holds(jget(field, "show_if"), answers)) return &field; } return nullptr; } std::string PlexerFlow::screen_text(const Json& screen, const Json* field) { auto or_title = [&](const Json& first) { const std::string& s = first.as_string(); return s.empty() ? jget(app, "title").as_string() : s; }; if (field != nullptr && field != first_askable(screen)) { const std::string& label = jget(*field, "label").as_string(); if (!label.empty()) return label; return or_title(jget(screen, "title")); } std::vector parts = {or_title(jget(screen, "title")), jget(screen, "body").as_string()}; for (const auto& item : jget(screen, "fields").as_array()) if (jget(item, "type").as_string() == "info" && condition_holds(jget(item, "show_if"), answers)) parts.push_back(jget(item, "text").as_string()); if (field != nullptr && !jget(*field, "label").as_string().empty()) parts.push_back(jget(*field, "label").as_string()); std::string out; bool first = true; for (const auto& part : parts) { if (part.empty() || py::strip(part).empty()) continue; if (!first) out += "\n\n"; first = false; out += part; } return out; } double PlexerFlow::remaining() const { return deadline - py::monotonic(); } namespace { Json outcome_for(const std::string& status, const std::string& at, const Json& answers, const std::string& field = "") { Json out = Json::object(); out.set("status", Json::string(status)); out.set("at", Json::string(at)); if (!field.empty()) out.set("field", Json::string(field)); out.set("answers", answers); return out; } } // namespace Json PlexerFlow::run(PlexerController& controller) { deadline = py::monotonic() + static_cast(jget(app, "timeout").as_int()); std::string current = jget(app, "start").as_string(); for (;;) { ++visits; if (visits > PLEXER_FLOW_VISITS_MAX) { Json out = Json::object(); out.set("status", Json::string("error")); out.set("error", Json::string("flow passed " + d(PLEXER_FLOW_VISITS_MAX) + " screens (loop?), aborting")); out.set("at", Json::string(current)); out.set("answers", answers); return out; } const Json& screen = by_id.at(current); std::string target; if (condition_holds(jget(screen, "show_if"), answers)) { ScreenResult result = run_screen(controller, screen); if (result.kind == ScreenResult::Kind::Outcome) return result.outcome; target = result.kind == ScreenResult::Kind::Jump ? result.target : follow_up(screen); } else { target = follow_up(screen); } if (target == "$done") { Json out = Json::object(); out.set("status", Json::string("done")); out.set("answers", answers); out.set("action", action); return out; } if (target == "$cancel") return outcome_for("cancelled", current, answers); current = target; } } PlexerFlow::ScreenResult PlexerFlow::run_screen(PlexerController& controller, const Json& screen) { auto outcome = [](Json value) { ScreenResult r; r.kind = ScreenResult::Kind::Outcome; r.outcome = std::move(value); return r; }; const std::string sid = jget(screen, "id").as_string(); const std::string screen_title = [&] { const std::string& t = jget(screen, "title").as_string(); return t.empty() ? jget(app, "title").as_string() : t; }(); bool asked = false; for (const auto& field : jget(screen, "fields").as_array()) { const std::string& type = jget(field, "type").as_string(); if (type == "info" || !condition_holds(jget(field, "show_if"), answers)) continue; if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers)); const std::string fid = jget(field, "id").as_string(); if (type == "choice") { Json ask = Json::object(); ask.set("title", Json::string(screen_title)); ask.set("text", Json::string(screen_text(screen, &field))); ask.set("kind", Json::string("choice")); Json labels = Json::array(); for (const auto& opt : jget(field, "options").as_array()) labels.push(jget(opt, "label")); ask.set("options", labels); ask.set("default", jget(field, "default")); Json reply = controller.ask(ask, remaining()); if (jget(reply, "status").as_string() != "ok") return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers, fid)); const Json& opts = jget(field, "options"); long long idx = jget(reply, "value").is_number() ? jget(reply, "value").as_int() : -1; if (idx < 0 || idx >= static_cast(opts.as_array().size())) throw PlexerError("list index out of range"); answers.set(fid, jget(opts.as_array()[static_cast(idx)], "value")); } else { Json ask = Json::object(); ask.set("title", Json::string(screen_title)); ask.set("text", Json::string(screen_text(screen, &field))); ask.set("kind", Json::string("text")); ask.set("initial", jget(field, "initial")); Json reply = controller.ask(ask, remaining()); if (jget(reply, "status").as_string() != "ok") return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers, fid)); answers.set(fid, jget(reply, "value")); } asked = true; } const Json& actions = jget(screen, "actions"); if (actions.as_array().empty()) { if (!asked) { if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers)); Json ask = Json::object(); ask.set("title", Json::string(screen_title)); ask.set("text", Json::string(screen_text(screen))); ask.set("kind", Json::string("message")); Json reply = controller.ask(ask, remaining()); if (jget(reply, "status").as_string() != "ok") return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers)); } return ScreenResult{}; } if (remaining() <= 0) return outcome(outcome_for("timeout", sid, answers)); std::string text; if (first_askable(screen) == nullptr && py::strip(jget(screen, "body").as_string()).empty()) text = screen_title; else if (first_askable(screen) == nullptr) text = screen_text(screen); else text = screen_title; Json ask = Json::object(); ask.set("title", Json::string(screen_title)); ask.set("text", Json::string(text)); ask.set("kind", Json::string("choice")); Json labels = Json::array(); for (const auto& a : actions.as_array()) labels.push(jget(a, "label")); ask.set("options", labels); ask.set("default", Json::integer(0)); Json reply = controller.ask(ask, remaining()); if (jget(reply, "status").as_string() != "ok") return outcome(outcome_for(jget(reply, "status").as_string(), sid, answers)); long long idx = jget(reply, "value").is_number() ? jget(reply, "value").as_int() : -1; if (idx < 0 || idx >= static_cast(actions.as_array().size())) throw PlexerError("list index out of range"); action = jget(actions.as_array()[static_cast(idx)], "id"); const Json& target = jget(screen, "next"); if (target.is_object()) { const Json* dest = target.get(action.as_string()); if (dest == nullptr || dest->is_null()) return ScreenResult{}; if (dest->as_string() == "$done") { Json out = Json::object(); out.set("status", Json::string("done")); out.set("answers", answers); out.set("action", action); return outcome(out); } if (dest->as_string() == "$cancel") return outcome(outcome_for("cancelled", sid, answers)); ScreenResult r; r.kind = ScreenResult::Kind::Jump; r.target = dest->as_string(); return r; } return ScreenResult{}; } } // namespace detz::mplex_flow