mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-08 23:33:16 +08:00
Add WhatsApp session bind/unbind to Admin Runtime, and fix related Admin JS errors.
Expose sidecar status, start/stop, QR bind, and unbind so 405 session failures are visible instead of a stuck connecting state. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1bdbfc3b85
commit
5c6833612f
16 changed files with 1123 additions and 72 deletions
|
|
@ -1767,6 +1767,83 @@ def build_admin_router() -> APIRouter:
|
||||||
)
|
)
|
||||||
return {"ok": deleted, "deleted": deleted}
|
return {"ok": deleted, "deleted": deleted}
|
||||||
|
|
||||||
|
@router.get("/admin/api/whatsapp/session")
|
||||||
|
def api_whatsapp_session_get(
|
||||||
|
channel_id: str = Query(default="whatsapp"),
|
||||||
|
account_id: str = Query(default=""),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from runtime.operations.whatsapp_sidecar import session_status
|
||||||
|
|
||||||
|
store = get_assistant_store()
|
||||||
|
ctx = _resolve_auth(store, authorization)
|
||||||
|
_require_permission(ctx, "admin:read")
|
||||||
|
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or "wa-default").strip()
|
||||||
|
return session_status(channel_id=str(channel_id or "whatsapp").strip() or "whatsapp", account_id=aid)
|
||||||
|
|
||||||
|
@router.post("/admin/api/whatsapp/session/start")
|
||||||
|
def api_whatsapp_session_start(
|
||||||
|
payload: dict[str, Any] | None = Body(default=None),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from runtime.operations.whatsapp_sidecar import start_sidecar
|
||||||
|
|
||||||
|
store = get_assistant_store()
|
||||||
|
ctx = _resolve_auth(store, authorization)
|
||||||
|
_require_permission(ctx, "admin:runtime:write")
|
||||||
|
body = payload or {}
|
||||||
|
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||||||
|
base = str(body.get("gateway_base_url") or os.getenv("AIA_GATEWAY_BASE_URL") or "http://127.0.0.1:8787").strip()
|
||||||
|
return start_sidecar(cid, gateway_base_url=base)
|
||||||
|
|
||||||
|
@router.post("/admin/api/whatsapp/session/stop")
|
||||||
|
def api_whatsapp_session_stop(
|
||||||
|
payload: dict[str, Any] | None = Body(default=None),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from runtime.operations.whatsapp_sidecar import session_status, stop_sidecar
|
||||||
|
|
||||||
|
store = get_assistant_store()
|
||||||
|
ctx = _resolve_auth(store, authorization)
|
||||||
|
_require_permission(ctx, "admin:runtime:write")
|
||||||
|
body = payload or {}
|
||||||
|
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||||||
|
force = str(body.get("force") or "1").strip().lower() not in ("0", "false", "no", "off")
|
||||||
|
result = stop_sidecar(cid, force=force)
|
||||||
|
result["status"] = session_status(channel_id=cid)
|
||||||
|
return result
|
||||||
|
|
||||||
|
@router.post("/admin/api/whatsapp/session/unbind")
|
||||||
|
def api_whatsapp_session_unbind(
|
||||||
|
payload: dict[str, Any] | None = Body(default=None),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from runtime.operations.whatsapp_sidecar import unbind_session
|
||||||
|
|
||||||
|
store = get_assistant_store()
|
||||||
|
ctx = _resolve_auth(store, authorization)
|
||||||
|
_require_permission(ctx, "admin:runtime:write")
|
||||||
|
body = payload or {}
|
||||||
|
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||||||
|
base = str(body.get("gateway_base_url") or os.getenv("AIA_GATEWAY_BASE_URL") or "http://127.0.0.1:8787").strip()
|
||||||
|
return unbind_session(cid, gateway_base_url=base)
|
||||||
|
|
||||||
|
@router.post("/admin/api/whatsapp/session/bind")
|
||||||
|
def api_whatsapp_session_bind(
|
||||||
|
payload: dict[str, Any] | None = Body(default=None),
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from runtime.operations.whatsapp_sidecar import start_bind
|
||||||
|
|
||||||
|
store = get_assistant_store()
|
||||||
|
ctx = _resolve_auth(store, authorization)
|
||||||
|
_require_permission(ctx, "admin:runtime:write")
|
||||||
|
body = payload or {}
|
||||||
|
cid = str(body.get("channel_id") or "whatsapp").strip() or "whatsapp"
|
||||||
|
base = str(body.get("gateway_base_url") or os.getenv("AIA_GATEWAY_BASE_URL") or "http://127.0.0.1:8787").strip()
|
||||||
|
clear_auth = str(body.get("clear_auth") or "").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
return start_bind(cid, gateway_base_url=base, clear_auth=clear_auth)
|
||||||
|
|
||||||
@router.get("/admin/api/users")
|
@router.get("/admin/api/users")
|
||||||
def api_users(
|
def api_users(
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>oliver</title>
|
<title>oliver</title>
|
||||||
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
<!-- Avoid render-blocking external fonts (can stall first paint ~30s on blocked networks). -->
|
||||||
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260812-22" />
|
<link rel="stylesheet" href="/admin/assets/styles.css?v=20260817-wa6" />
|
||||||
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260812-22" />
|
<link rel="stylesheet" href="/admin/assets/theme-deepseek.css?v=20260817-wa6" />
|
||||||
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260812-22" />
|
<link rel="stylesheet" href="/admin/assets/theme-presets.css?v=20260817-wa6" />
|
||||||
<style>
|
<style>
|
||||||
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
/* First paint before app.js runs: keep admin from showing a blank dark screen. */
|
||||||
.admin-boot-splash {
|
.admin-boot-splash {
|
||||||
|
|
@ -129,33 +129,33 @@
|
||||||
<script type="importmap">
|
<script type="importmap">
|
||||||
{
|
{
|
||||||
"imports": {
|
"imports": {
|
||||||
"/admin/assets/app.js": "/admin/assets/app.js?v=20260812-22",
|
"/admin/assets/app.js": "/admin/assets/app.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260812-22",
|
"/admin/assets/js/asset-v.js": "/admin/assets/js/asset-v.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260812-22",
|
"/admin/assets/js/core.js": "/admin/assets/js/core.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260812-22",
|
"/admin/assets/js/i18n/index.js": "/admin/assets/js/i18n/index.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260812-22",
|
"/admin/assets/js/i18n/messages-en.js": "/admin/assets/js/i18n/messages-en.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260812-22",
|
"/admin/assets/js/i18n/messages-zh.js": "/admin/assets/js/i18n/messages-zh.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260812-22",
|
"/admin/assets/js/main.js": "/admin/assets/js/main.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260812-22",
|
"/admin/assets/js/pages/attachments.js": "/admin/assets/js/pages/attachments.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260812-22",
|
"/admin/assets/js/pages/audit.js": "/admin/assets/js/pages/audit.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260812-22",
|
"/admin/assets/js/pages/authz.js": "/admin/assets/js/pages/authz.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260812-22",
|
"/admin/assets/js/pages/legacy-admin.js": "/admin/assets/js/pages/legacy-admin.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260812-22",
|
"/admin/assets/js/pages/legacy.js": "/admin/assets/js/pages/legacy.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260812-22",
|
"/admin/assets/js/pages/login.js": "/admin/assets/js/pages/login.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260812-22",
|
"/admin/assets/js/pages/memory.js": "/admin/assets/js/pages/memory.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260812-22",
|
"/admin/assets/js/pages/models.js": "/admin/assets/js/pages/models.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260812-22",
|
"/admin/assets/js/pages/plugins.js": "/admin/assets/js/pages/plugins.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260812-22",
|
"/admin/assets/js/pages/profile.js": "/admin/assets/js/pages/profile.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260812-22",
|
"/admin/assets/js/pages/scheduled-jobs.js": "/admin/assets/js/pages/scheduled-jobs.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260812-22",
|
"/admin/assets/js/pages/skills.js": "/admin/assets/js/pages/skills.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260812-22",
|
"/admin/assets/js/pages/stack.js": "/admin/assets/js/pages/stack.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260812-22",
|
"/admin/assets/js/pages/users.js": "/admin/assets/js/pages/users.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260812-22",
|
"/admin/assets/js/pages/workspace-paths.js": "/admin/assets/js/pages/workspace-paths.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260812-22",
|
"/admin/assets/js/router.js": "/admin/assets/js/router.js?v=20260817-wa6",
|
||||||
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260812-22"
|
"/admin/assets/js/state.js": "/admin/assets/js/state.js?v=20260817-wa6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script type="module" src="/admin/assets/app.js?v=20260812-22" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
<script type="module" src="/admin/assets/app.js?v=20260817-wa6" onerror="(function(){var lang='zh';try{lang=String(localStorage.getItem('ops_admin_lang')||'zh').toLowerCase();}catch(e){}if(lang!=='en')lang='zh';var msg=lang==='en'?{t:'Failed to load admin scripts',h:'Confirm /admin/assets/app.js and /admin/assets/js/ are reachable.'}:{t:'无法加载管理台脚本',h:'请确认 /admin/assets/app.js 与 /admin/assets/js/ 可访问。'};var s=document.getElementById('admin-boot-splash');if(s){var a=s.querySelector('.admin-boot-splash__title span:last-child');var b=s.querySelector('.admin-boot-splash__muted');if(a)a.textContent=msg.t;if(b)b.textContent=msg.h;}})()"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -611,6 +611,9 @@ function markPrewarmReminder(reason) {
|
||||||
const base = t("stack.configChangedPrewarm");
|
const base = t("stack.configChangedPrewarm");
|
||||||
runtimePrewarmReminder = why ? `${base} [${why}]` : base;
|
runtimePrewarmReminder = why ? `${base} [${why}]` : base;
|
||||||
}
|
}
|
||||||
|
function clearPrewarmReminder() {
|
||||||
|
runtimePrewarmReminder = "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compact row action menu (button + fixed popover).
|
* Compact row action menu (button + fixed popover).
|
||||||
|
|
@ -754,6 +757,7 @@ export {
|
||||||
yesNo,
|
yesNo,
|
||||||
runtimePrewarmReminder,
|
runtimePrewarmReminder,
|
||||||
markPrewarmReminder,
|
markPrewarmReminder,
|
||||||
|
clearPrewarmReminder,
|
||||||
rowActions,
|
rowActions,
|
||||||
navigateAdmin,
|
navigateAdmin,
|
||||||
state,
|
state,
|
||||||
|
|
|
||||||
|
|
@ -782,6 +782,30 @@ export default {
|
||||||
"wa.specialist": "Specialist",
|
"wa.specialist": "Specialist",
|
||||||
"wa.testPush": "Test push",
|
"wa.testPush": "Test push",
|
||||||
"wa.whitelistMode": "Whitelist mode (allow by default)",
|
"wa.whitelistMode": "Whitelist mode (allow by default)",
|
||||||
|
"wa.sessionTitle": "WhatsApp device session",
|
||||||
|
"wa.sessionHint": "Show online status; scan to bind or clear the local WhatsApp Web session.",
|
||||||
|
"wa.sessionRefresh": "Refresh",
|
||||||
|
"wa.sessionStart": "Start sidecar",
|
||||||
|
"wa.sessionStop": "Stop sidecar",
|
||||||
|
"wa.sessionBind": "Scan to bind",
|
||||||
|
"wa.sessionRebind": "Re-scan bind",
|
||||||
|
"wa.sessionUnbind": "Unbind device",
|
||||||
|
"wa.sessionUnbindConfirm": "Stop sidecar and delete local WhatsApp auth (state/auth). You must scan again to rebind. Continue?",
|
||||||
|
"wa.sessionRebindConfirm": "Clear the old session and show a new QR code. Continue?",
|
||||||
|
"wa.sessionNotInstalled": "Sidecar not installed. Run whatsapp_install.ps1 first.",
|
||||||
|
"wa.sessionScanHint": "On your phone: WhatsApp → Linked devices → Link a device, then scan the QR below.",
|
||||||
|
"wa.sessionQrWaiting": "Waiting for QR…",
|
||||||
|
"wa.sessionQrStale": "QR may be expired. Click Scan to bind again.",
|
||||||
|
"wa.lifecycle.unbound": "Not bound",
|
||||||
|
"wa.lifecycle.bound_stopped": "Bound (sidecar stopped)",
|
||||||
|
"wa.lifecycle.bound_offline": "Bound (offline / reconnecting)",
|
||||||
|
"wa.lifecycle.online": "Online",
|
||||||
|
"wa.lifecycle.awaiting_scan": "Awaiting scan",
|
||||||
|
"wa.lifecycle.logged_out": "Logged out (rebind required)",
|
||||||
|
"wa.lifecycle.connecting": "Connecting",
|
||||||
|
"wa.lifecycle.needs_rebind": "Session invalid (re-scan required)",
|
||||||
|
"wa.sessionPolling": "Refreshing status…",
|
||||||
|
"wa.sessionNeedsRebind": "WhatsApp rejected the stored session (e.g. disconnect=405). Click Re-scan bind, or remove the old oclaw linked device on your phone first.",
|
||||||
"wecom.autoBind": "Auto bind on first message",
|
"wecom.autoBind": "Auto bind on first message",
|
||||||
"wecom.autoBindRole": "Auto bind role",
|
"wecom.autoBindRole": "Auto bind role",
|
||||||
"wecom.autoBindTenant": "Auto bind team",
|
"wecom.autoBindTenant": "Auto bind team",
|
||||||
|
|
|
||||||
|
|
@ -782,6 +782,30 @@ export default {
|
||||||
"wa.specialist": "专家",
|
"wa.specialist": "专家",
|
||||||
"wa.testPush": "测试推送",
|
"wa.testPush": "测试推送",
|
||||||
"wa.whitelistMode": "白名单模式(默认放行,黑名单拒绝)",
|
"wa.whitelistMode": "白名单模式(默认放行,黑名单拒绝)",
|
||||||
|
"wa.sessionTitle": "WhatsApp 设备会话",
|
||||||
|
"wa.sessionHint": "查看在线状态,扫码绑定或解绑本机 WhatsApp Web 登录态。",
|
||||||
|
"wa.sessionRefresh": "刷新状态",
|
||||||
|
"wa.sessionStart": "启动 sidecar",
|
||||||
|
"wa.sessionStop": "停止 sidecar",
|
||||||
|
"wa.sessionBind": "扫码绑定",
|
||||||
|
"wa.sessionRebind": "重新扫码绑定",
|
||||||
|
"wa.sessionUnbind": "解绑设备",
|
||||||
|
"wa.sessionUnbindConfirm": "将停止 sidecar 并删除本机 WhatsApp 登录态(state/auth)。之后需重新扫码绑定,确认继续?",
|
||||||
|
"wa.sessionRebindConfirm": "将清除旧登录态并重新出二维码,确认继续?",
|
||||||
|
"wa.sessionNotInstalled": "sidecar 未安装,请先运行 whatsapp_install.ps1",
|
||||||
|
"wa.sessionScanHint": "请用手机 WhatsApp → 已关联的设备 → 关联设备,扫描下方二维码。",
|
||||||
|
"wa.sessionQrWaiting": "等待二维码…",
|
||||||
|
"wa.sessionQrStale": "二维码可能已过期,请点「扫码绑定」刷新。",
|
||||||
|
"wa.lifecycle.unbound": "未绑定",
|
||||||
|
"wa.lifecycle.bound_stopped": "已绑定(sidecar 已停)",
|
||||||
|
"wa.lifecycle.bound_offline": "已绑定(离线重连中)",
|
||||||
|
"wa.lifecycle.online": "在线",
|
||||||
|
"wa.lifecycle.awaiting_scan": "待扫码",
|
||||||
|
"wa.lifecycle.logged_out": "已登出(需重新绑定)",
|
||||||
|
"wa.lifecycle.connecting": "连接中",
|
||||||
|
"wa.lifecycle.needs_rebind": "登录态失效(需重新扫码)",
|
||||||
|
"wa.sessionPolling": "正在刷新状态…",
|
||||||
|
"wa.sessionNeedsRebind": "WhatsApp 拒绝了当前登录态(disconnect=405 等)。请点「重新扫码绑定」,或在手机端删除旧的 oclaw 关联设备后再扫。",
|
||||||
"wecom.autoBind": "首条消息自动绑定",
|
"wecom.autoBind": "首条消息自动绑定",
|
||||||
"wecom.autoBindRole": "自动绑定角色",
|
"wecom.autoBindRole": "自动绑定角色",
|
||||||
"wecom.autoBindTenant": "自动绑定团队",
|
"wecom.autoBindTenant": "自动绑定团队",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { t, el, tdCell, apiGet, apiPost, CHAT_MEMORY_MODE_KEY, navigateAdmin } from "../core.js";
|
import { t, el, tdCell, apiGet, apiPost, CHAT_MEMORY_MODE_KEY, navigateAdmin, getRoute } from "../core.js";
|
||||||
|
|
||||||
async function renderMemory() {
|
async function renderMemory() {
|
||||||
const route = getRoute();
|
const route = getRoute();
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ async function renderModels() {
|
||||||
evalDetails.appendChild(evalSummary);
|
evalDetails.appendChild(evalSummary);
|
||||||
evalDetails.appendChild(evalInner);
|
evalDetails.appendChild(evalInner);
|
||||||
|
|
||||||
let state = null;
|
let modelsState = null;
|
||||||
let evalPack = null;
|
let evalPack = null;
|
||||||
let secretsStatus = null;
|
let secretsStatus = null;
|
||||||
let expertsState = { items: [] };
|
let expertsState = { items: [] };
|
||||||
|
|
@ -179,7 +179,7 @@ async function renderModels() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelFor(pid) {
|
function labelFor(pid) {
|
||||||
const profiles = (state && state.profiles) || [];
|
const profiles = (modelsState && modelsState.profiles) || [];
|
||||||
const p = profiles.find((x) => String(x.id) === String(pid));
|
const p = profiles.find((x) => String(x.id) === String(pid));
|
||||||
const base = (p && String(p.name || "").trim()) || pid;
|
const base = (p && String(p.name || "").trim()) || pid;
|
||||||
const vr = p && p.visibility_reason;
|
const vr = p && p.visibility_reason;
|
||||||
|
|
@ -215,7 +215,7 @@ async function renderModels() {
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
try {
|
try {
|
||||||
const data = await apiGet("/admin/api/models");
|
const data = await apiGet("/admin/api/models");
|
||||||
state = data;
|
modelsState = data;
|
||||||
status.textContent = "";
|
status.textContent = "";
|
||||||
paint();
|
paint();
|
||||||
try {
|
try {
|
||||||
|
|
@ -239,7 +239,7 @@ async function renderModels() {
|
||||||
}
|
}
|
||||||
paintExperts();
|
paintExperts();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
state = null;
|
modelsState = null;
|
||||||
evalPack = null;
|
evalPack = null;
|
||||||
secretsStatus = null;
|
secretsStatus = null;
|
||||||
expertsState = { ok: false, items: [] };
|
expertsState = { ok: false, items: [] };
|
||||||
|
|
@ -314,21 +314,21 @@ async function renderModels() {
|
||||||
|
|
||||||
function paintBindings() {
|
function paintBindings() {
|
||||||
bindingWrap.innerHTML = "";
|
bindingWrap.innerHTML = "";
|
||||||
if (!state || !state.bindings) return;
|
if (!modelsState || !modelsState.bindings) return;
|
||||||
const profiles = state.profiles || [];
|
const profiles = modelsState.profiles || [];
|
||||||
const profileIds = profiles.map((p) => String(p.id));
|
const profileIds = profiles.map((p) => String(p.id));
|
||||||
const roleIds = Array.isArray(state.role_ids) ? state.role_ids : [];
|
const roleIds = Array.isArray(modelsState.role_ids) ? modelsState.role_ids : [];
|
||||||
roleIds.forEach((rid) => {
|
roleIds.forEach((rid) => {
|
||||||
const sel = el("select", { class: "input", disabled: !canConfigureBindings });
|
const sel = el("select", { class: "input", disabled: !canConfigureBindings });
|
||||||
sel.appendChild(el("option", { value: "", text: t("models.useGlobal") }));
|
sel.appendChild(el("option", { value: "", text: t("models.useGlobal") }));
|
||||||
profiles.forEach((p) => {
|
profiles.forEach((p) => {
|
||||||
sel.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
sel.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
||||||
});
|
});
|
||||||
const v = String(state.bindings[rid] || "").trim();
|
const v = String(modelsState.bindings[rid] || "").trim();
|
||||||
sel.value = profileIds.includes(v) ? v : "";
|
sel.value = profileIds.includes(v) ? v : "";
|
||||||
sel.addEventListener("change", async () => {
|
sel.addEventListener("change", async () => {
|
||||||
if (!canConfigureBindings) return;
|
if (!canConfigureBindings) return;
|
||||||
const next = Object.assign({}, state.bindings);
|
const next = Object.assign({}, modelsState.bindings);
|
||||||
next[rid] = String(sel.value || "");
|
next[rid] = String(sel.value || "");
|
||||||
try {
|
try {
|
||||||
await apiPost("/admin/api/models/bindings", { bindings: next });
|
await apiPost("/admin/api/models/bindings", { bindings: next });
|
||||||
|
|
@ -345,10 +345,10 @@ async function renderModels() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function paintOpsAiBindings() {
|
function paintOpsAiBindings() {
|
||||||
if (!state || !state.ops_ai_bindings) return;
|
if (!modelsState || !modelsState.ops_ai_bindings) return;
|
||||||
const profiles = state.profiles || [];
|
const profiles = modelsState.profiles || [];
|
||||||
const profileIds = profiles.map((p) => String(p.id));
|
const profileIds = profiles.map((p) => String(p.id));
|
||||||
const roleIds = (Array.isArray(state.role_ids) ? state.role_ids : []).filter((rid) => String(rid || "") !== "manager");
|
const roleIds = (Array.isArray(modelsState.role_ids) ? modelsState.role_ids : []).filter((rid) => String(rid || "") !== "manager");
|
||||||
|
|
||||||
const curSpecialist = String(opsAiSpecialistSelect.value || "").trim();
|
const curSpecialist = String(opsAiSpecialistSelect.value || "").trim();
|
||||||
opsAiSpecialistSelect.innerHTML = "";
|
opsAiSpecialistSelect.innerHTML = "";
|
||||||
|
|
@ -363,7 +363,7 @@ async function renderModels() {
|
||||||
profiles.forEach((p) => {
|
profiles.forEach((p) => {
|
||||||
opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
opsAiProfileSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
||||||
});
|
});
|
||||||
const v = String((state.ops_ai_bindings && state.ops_ai_bindings[rid]) || "").trim();
|
const v = String((modelsState.ops_ai_bindings && modelsState.ops_ai_bindings[rid]) || "").trim();
|
||||||
opsAiProfileSelect.value = profileIds.includes(v) ? v : "";
|
opsAiProfileSelect.value = profileIds.includes(v) ? v : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -414,22 +414,22 @@ async function renderModels() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function paint() {
|
function paint() {
|
||||||
if (!state) return;
|
if (!modelsState) return;
|
||||||
if (state.ok !== true) {
|
if (modelsState.ok !== true) {
|
||||||
status.textContent = status.textContent || t("models.loadFailed");
|
status.textContent = status.textContent || t("models.loadFailed");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const profiles = Array.isArray(state.profiles) ? state.profiles : [];
|
const profiles = Array.isArray(modelsState.profiles) ? modelsState.profiles : [];
|
||||||
const builtin = String(state.builtin_ollama_profile_id || "");
|
const builtin = String(modelsState.builtin_ollama_profile_id || "");
|
||||||
activeSelect.innerHTML = "";
|
activeSelect.innerHTML = "";
|
||||||
profiles.forEach((p) => {
|
profiles.forEach((p) => {
|
||||||
activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
activeSelect.appendChild(el("option", { value: String(p.id), text: labelFor(p.id) }));
|
||||||
});
|
});
|
||||||
const aid = String(state.active_llm_profile_id || "");
|
const aid = String(modelsState.active_llm_profile_id || "");
|
||||||
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
|
if (profiles.some((p) => String(p.id) === aid)) activeSelect.value = aid;
|
||||||
activeSelect.disabled = !canPickActive;
|
activeSelect.disabled = !canPickActive;
|
||||||
chatModelSelectorVisibleCb.disabled = !canConfigureChatUi;
|
chatModelSelectorVisibleCb.disabled = !canConfigureChatUi;
|
||||||
chatModelSelectorVisibleCb.checked = state.chat_model_selector_visible !== false;
|
chatModelSelectorVisibleCb.checked = modelsState.chat_model_selector_visible !== false;
|
||||||
opsAiSpecialistSelect.disabled = !canConfigureBindings;
|
opsAiSpecialistSelect.disabled = !canConfigureBindings;
|
||||||
opsAiProfileSelect.disabled = !canConfigureBindings;
|
opsAiProfileSelect.disabled = !canConfigureBindings;
|
||||||
|
|
||||||
|
|
@ -460,7 +460,7 @@ async function renderModels() {
|
||||||
baseInp.value = String(selProf.base_url || "");
|
baseInp.value = String(selProf.base_url || "");
|
||||||
thinkingModeCb.checked = !!selProf.thinking_mode_enabled;
|
thinkingModeCb.checked = !!selProf.thinking_mode_enabled;
|
||||||
reasoningEffortSel.value = String(selProf.reasoning_effort || "");
|
reasoningEffortSel.value = String(selProf.reasoning_effort || "");
|
||||||
keyInp.value = String(state.profile_secret || "");
|
keyInp.value = String(modelsState.profile_secret || "");
|
||||||
rememberCb.checked = !!selProf.has_key;
|
rememberCb.checked = !!selProf.has_key;
|
||||||
|
|
||||||
profName.disabled = !canEditFields;
|
profName.disabled = !canEditFields;
|
||||||
|
|
@ -477,7 +477,7 @@ async function renderModels() {
|
||||||
ollamaHint.textContent = "";
|
ollamaHint.textContent = "";
|
||||||
if (m === "openai" || m === "openai_responses") {
|
if (m === "openai" || m === "openai_responses") {
|
||||||
ollamaHint.textContent = "";
|
ollamaHint.textContent = "";
|
||||||
if (state.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint");
|
if (modelsState.has_openai_api_key_env) openaiHint.textContent = t("models.openaiKeyHint");
|
||||||
if (selProf.has_key && !String(keyInp.value || "").trim()) {
|
if (selProf.has_key && !String(keyInp.value || "").trim()) {
|
||||||
warnKey.textContent = t("models.warnKeyInDb");
|
warnKey.textContent = t("models.warnKeyInDb");
|
||||||
}
|
}
|
||||||
|
|
@ -491,7 +491,7 @@ async function renderModels() {
|
||||||
btnDelete.disabled = !canMutateProfiles || !rowMutable || pid === builtin;
|
btnDelete.disabled = !canMutateProfiles || !rowMutable || pid === builtin;
|
||||||
|
|
||||||
modelsGrantsLinkRow.innerHTML = "";
|
modelsGrantsLinkRow.innerHTML = "";
|
||||||
if (state.can_manage_llm_grants) {
|
if (modelsState.can_manage_llm_grants) {
|
||||||
modelsGrantsLinkRow.style.display = "block";
|
modelsGrantsLinkRow.style.display = "block";
|
||||||
modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` }));
|
modelsGrantsLinkRow.appendChild(el("span", { text: `${t("models.grantsNavHint")} ` }));
|
||||||
modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") }));
|
modelsGrantsLinkRow.appendChild(el("a", { href: "#/api-grants", text: t("models.linkApiGrants") }));
|
||||||
|
|
@ -529,7 +529,7 @@ async function renderModels() {
|
||||||
if (!canConfigureBindings) return;
|
if (!canConfigureBindings) return;
|
||||||
try {
|
try {
|
||||||
const rid = String(opsAiSpecialistSelect.value || "").trim();
|
const rid = String(opsAiSpecialistSelect.value || "").trim();
|
||||||
const next = Object.assign({}, (state && state.ops_ai_bindings) || {});
|
const next = Object.assign({}, (modelsState && modelsState.ops_ai_bindings) || {});
|
||||||
next[rid] = String(opsAiProfileSelect.value || "");
|
next[rid] = String(opsAiProfileSelect.value || "");
|
||||||
await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next });
|
await apiPost("/admin/api/models/ops-ai/bindings", { bindings: next });
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
@ -554,10 +554,10 @@ async function renderModels() {
|
||||||
|
|
||||||
const btnSave = el("button", { class: "btn btn--primary", text: t("models.save"), onclick: async () => {
|
const btnSave = el("button", { class: "btn btn--primary", text: t("models.save"), onclick: async () => {
|
||||||
if (!canMutateProfiles) return;
|
if (!canMutateProfiles) return;
|
||||||
const sel = (state.profiles || []).find((p) => String(p.id) === String(state.active_llm_profile_id || ""));
|
const sel = (modelsState.profiles || []).find((p) => String(p.id) === String(modelsState.active_llm_profile_id || ""));
|
||||||
if (sel && sel.mutable === false) return;
|
if (sel && sel.mutable === false) return;
|
||||||
const pid = String(state.active_llm_profile_id || "");
|
const pid = String(modelsState.active_llm_profile_id || "");
|
||||||
const builtin = String(state.builtin_ollama_profile_id || "");
|
const builtin = String(modelsState.builtin_ollama_profile_id || "");
|
||||||
try {
|
try {
|
||||||
let modeSave = modeSel.value;
|
let modeSave = modeSel.value;
|
||||||
if (pid === builtin) modeSave = "ollama";
|
if (pid === builtin) modeSave = "ollama";
|
||||||
|
|
@ -581,8 +581,8 @@ async function renderModels() {
|
||||||
|
|
||||||
const btnDelete = el("button", { class: "btn btn--danger", text: t("models.delete"), onclick: async () => {
|
const btnDelete = el("button", { class: "btn btn--danger", text: t("models.delete"), onclick: async () => {
|
||||||
if (!canMutateProfiles) return;
|
if (!canMutateProfiles) return;
|
||||||
const pid = String(state.active_llm_profile_id || "");
|
const pid = String(modelsState.active_llm_profile_id || "");
|
||||||
const builtin = String(state.builtin_ollama_profile_id || "");
|
const builtin = String(modelsState.builtin_ollama_profile_id || "");
|
||||||
if (pid === builtin) {
|
if (pid === builtin) {
|
||||||
status.textContent = t("models.cannotDeleteBuiltin");
|
status.textContent = t("models.cannotDeleteBuiltin");
|
||||||
return;
|
return;
|
||||||
|
|
@ -709,7 +709,7 @@ async function renderModels() {
|
||||||
|
|
||||||
await refresh();
|
await refresh();
|
||||||
|
|
||||||
if (!state) {
|
if (!modelsState) {
|
||||||
return el("div", {}, [
|
return el("div", {}, [
|
||||||
el("div", { class: "card" }, [
|
el("div", { class: "card" }, [
|
||||||
el("div", { class: "card__title", text: t("title.models") }),
|
el("div", { class: "card__title", text: t("title.models") }),
|
||||||
|
|
@ -719,32 +719,32 @@ async function renderModels() {
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.ok === true && Array.isArray(state.profiles) && state.profiles.length === 0) {
|
if (modelsState.ok === true && Array.isArray(modelsState.profiles) && modelsState.profiles.length === 0) {
|
||||||
const noProfBody = [
|
const noProfBody = [
|
||||||
el("div", { class: "card__title", text: t("title.models") }),
|
el("div", { class: "card__title", text: t("title.models") }),
|
||||||
el("div", { class: "muted", text: t("models.noProfiles") }),
|
el("div", { class: "muted", text: t("models.noProfiles") }),
|
||||||
];
|
];
|
||||||
if (state.db_path) {
|
if (modelsState.db_path) {
|
||||||
noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` }));
|
noProfBody.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${modelsState.db_path}` }));
|
||||||
}
|
}
|
||||||
noProfBody.push(status);
|
noProfBody.push(status);
|
||||||
return el("div", {}, [el("div", { class: "card" }, noProfBody)]);
|
return el("div", {}, [el("div", { class: "card" }, noProfBody)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.ok !== true) {
|
if (modelsState.ok !== true) {
|
||||||
return el("div", {}, [
|
return el("div", {}, [
|
||||||
el("div", { class: "card" }, [
|
el("div", { class: "card" }, [
|
||||||
el("div", { class: "card__title", text: t("title.models") }),
|
el("div", { class: "card__title", text: t("title.models") }),
|
||||||
el("div", { class: "muted", text: t("models.loadFailed") }),
|
el("div", { class: "muted", text: t("models.loadFailed") }),
|
||||||
el("pre", { class: "pre", text: JSON.stringify(state, null, 2) }),
|
el("pre", { class: "pre", text: JSON.stringify(modelsState, null, 2) }),
|
||||||
status,
|
status,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topBits = [readonlyHint, status, modelsGrantsLinkRow];
|
const topBits = [readonlyHint, status, modelsGrantsLinkRow];
|
||||||
if (state.db_path) {
|
if (modelsState.db_path) {
|
||||||
topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${state.db_path}` }));
|
topBits.push(el("div", { class: "muted", text: `${t("models.dbPath")}: ${modelsState.db_path}` }));
|
||||||
}
|
}
|
||||||
// Show secret migration helper when legacy secrets exist.
|
// Show secret migration helper when legacy secrets exist.
|
||||||
topBits.push(secretsCard);
|
topBits.push(secretsCard);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf } from "../core.js";
|
import { t, el, tdCell, apiGet, apiGetNoHang, apiPost, apiDeleteJson, formatSystemLocalDateTime, renderMetaChips, navigateAdmin, tf, runtimePrewarmReminder, clearPrewarmReminder } from "../core.js";
|
||||||
|
|
||||||
async function renderStack() {
|
async function renderStack() {
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
|
|
@ -10,6 +10,7 @@ async function renderStack() {
|
||||||
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"),
|
apiGetNoHang("/admin/api/chat/settings/channel-dispatch/whatsapp"),
|
||||||
apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"),
|
apiGetNoHang("/admin/api/whatsapp/groups?tenant_id=default"),
|
||||||
apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"),
|
apiGetNoHang("/admin/api/whatsapp/access?tenant_id=default"),
|
||||||
|
apiGetNoHang("/admin/api/whatsapp/session"),
|
||||||
]);
|
]);
|
||||||
const scanResp = results[0].status === "fulfilled" ? results[0].value : null;
|
const scanResp = results[0].status === "fulfilled" ? results[0].value : null;
|
||||||
const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null;
|
const prewarmStatusResp = results[1].status === "fulfilled" ? results[1].value : null;
|
||||||
|
|
@ -19,6 +20,7 @@ async function renderStack() {
|
||||||
const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null;
|
const whatsappDispatchResp = results[5].status === "fulfilled" ? results[5].value : null;
|
||||||
const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null;
|
const whatsappGroupsResp = results[6].status === "fulfilled" ? results[6].value : null;
|
||||||
const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null;
|
const whatsappAccessResp = results[7].status === "fulfilled" ? results[7].value : null;
|
||||||
|
const whatsappSessionResp = results[8].status === "fulfilled" ? results[8].value : null;
|
||||||
|
|
||||||
// If auth failed (401), show a gentle message instead of an infinite spinner.
|
// If auth failed (401), show a gentle message instead of an infinite spinner.
|
||||||
if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) {
|
if (!channelSpecResp && !weixinDispatchResp && !prewarmStatusResp) {
|
||||||
|
|
@ -91,6 +93,206 @@ async function renderStack() {
|
||||||
};
|
};
|
||||||
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
|
const weixinDispatchCard = createChannelDispatchCard("weixin", "Weixin dispatch", weixinDispatchResp || {});
|
||||||
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
|
const whatsappDispatchCard = createChannelDispatchCard("whatsapp", "WhatsApp dispatch", whatsappDispatchResp || {});
|
||||||
|
let waSessionLatest = whatsappSessionResp && typeof whatsappSessionResp === "object" ? whatsappSessionResp : {};
|
||||||
|
let waSessionBindInProgress = false;
|
||||||
|
const waSessionHintAlert = el("div", { class: "alert alert--warning u-hidden", text: "" });
|
||||||
|
const paintWaSession = (st) => {
|
||||||
|
const s = st && typeof st === "object" ? st : {};
|
||||||
|
waSessionLatest = s;
|
||||||
|
const lifecycle = String(s.lifecycle || "unbound").trim() || "unbound";
|
||||||
|
const lifeLabel = t(`wa.lifecycle.${lifecycle}`) || lifecycle;
|
||||||
|
const chips = renderMetaChips({
|
||||||
|
lifecycle: lifeLabel,
|
||||||
|
connection: String(s.connection || "-"),
|
||||||
|
phone: String(s.phone || "-"),
|
||||||
|
sidecar: s.sidecar_running ? `pid ${s.pid || "-"}` : "stopped",
|
||||||
|
reconnect: Number(s.reconnect_attempt || 0),
|
||||||
|
});
|
||||||
|
waSessionStatus.replaceChildren(...Array.from(chips.childNodes));
|
||||||
|
const detailBits = [
|
||||||
|
s.display_name ? `name=${s.display_name}` : "",
|
||||||
|
s.me ? `me=${s.me}` : "",
|
||||||
|
s.last_error ? `err=${String(s.last_error).slice(0, 120)}` : "",
|
||||||
|
s.last_disconnect_reason ? `disconnect=${s.last_disconnect_reason}` : "",
|
||||||
|
s.bridge_updated_at ? `updated=${s.bridge_updated_at}` : "",
|
||||||
|
].filter(Boolean);
|
||||||
|
waSessionDetail.textContent = detailBits.join(" | ") || (s.installed ? "" : t("wa.sessionNotInstalled"));
|
||||||
|
if (lifecycle === "needs_rebind" || lifecycle === "logged_out") {
|
||||||
|
waSessionHintAlert.textContent = t("wa.sessionNeedsRebind");
|
||||||
|
waSessionHintAlert.classList.remove("u-hidden");
|
||||||
|
} else {
|
||||||
|
waSessionHintAlert.textContent = "";
|
||||||
|
waSessionHintAlert.classList.add("u-hidden");
|
||||||
|
}
|
||||||
|
const qrUrl = String(s.qr_data_url || "").trim();
|
||||||
|
const awaiting = lifecycle === "awaiting_scan" || waSessionBindInProgress;
|
||||||
|
waSessionQrWrap.style.display = awaiting ? "" : "none";
|
||||||
|
waSessionQrHint.textContent = awaiting
|
||||||
|
? (s.qr_stale ? t("wa.sessionQrStale") : t("wa.sessionScanHint"))
|
||||||
|
: "";
|
||||||
|
if (awaiting && qrUrl) {
|
||||||
|
waSessionQrImg.src = qrUrl;
|
||||||
|
waSessionQrImg.style.display = "";
|
||||||
|
waSessionQrWaiting.style.display = "none";
|
||||||
|
} else if (awaiting) {
|
||||||
|
waSessionQrImg.removeAttribute("src");
|
||||||
|
waSessionQrImg.style.display = "none";
|
||||||
|
waSessionQrWaiting.style.display = "";
|
||||||
|
waSessionQrWaiting.textContent = t("wa.sessionQrWaiting");
|
||||||
|
} else {
|
||||||
|
waSessionQrImg.removeAttribute("src");
|
||||||
|
waSessionQrImg.style.display = "none";
|
||||||
|
waSessionQrWaiting.style.display = "none";
|
||||||
|
}
|
||||||
|
const bindModes = new Set(["unbound", "logged_out", "awaiting_scan", "needs_rebind"]);
|
||||||
|
if (waSessionBindBtn) {
|
||||||
|
waSessionBindBtn.textContent = bindModes.has(lifecycle)
|
||||||
|
? t("wa.sessionBind")
|
||||||
|
: t("wa.sessionRebind");
|
||||||
|
waSessionBindBtn.disabled = Boolean(waSessionBindInProgress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const waSessionStatus = el("div", { class: "muted" });
|
||||||
|
const waSessionDetail = el("div", { class: "muted u-mt-8" });
|
||||||
|
const waSessionBindBtn = el("button", {
|
||||||
|
class: "btn btn--primary",
|
||||||
|
text: t("wa.sessionBind"),
|
||||||
|
});
|
||||||
|
const waSessionQrImg = el("img", {
|
||||||
|
alt: "WhatsApp QR",
|
||||||
|
style: "width:240px;height:240px;background:#fff;border:1px solid var(--border, #ddd);display:none;",
|
||||||
|
});
|
||||||
|
const waSessionQrWaiting = el("div", { class: "muted", style: "display:none;" });
|
||||||
|
const waSessionQrHint = el("div", { class: "card__hint" });
|
||||||
|
const waSessionQrWrap = el("div", { class: "u-mt-10", style: "display:none;" }, [
|
||||||
|
waSessionQrHint,
|
||||||
|
waSessionQrImg,
|
||||||
|
waSessionQrWaiting,
|
||||||
|
]);
|
||||||
|
const waSessionActionStatus = el("div", { class: "muted u-mt-8", text: "" });
|
||||||
|
const pollWaSession = async (opts = {}) => {
|
||||||
|
const maxMs = Number(opts.maxMs || 60000);
|
||||||
|
const stepMs = Number(opts.stepMs || 2000);
|
||||||
|
const stopWhen = opts.stopWhen || ((st) => {
|
||||||
|
const life = String((st && st.lifecycle) || "");
|
||||||
|
return ["online", "awaiting_scan", "needs_rebind", "logged_out", "bound_stopped", "unbound"].includes(life);
|
||||||
|
});
|
||||||
|
waSessionActionStatus.textContent = t("wa.sessionPolling");
|
||||||
|
let waited = 0;
|
||||||
|
let latest = waSessionLatest;
|
||||||
|
while (waited < maxMs) {
|
||||||
|
const st = await apiGet("/admin/api/whatsapp/session");
|
||||||
|
paintWaSession(st);
|
||||||
|
latest = st;
|
||||||
|
if (stopWhen(st)) break;
|
||||||
|
await new Promise((r) => setTimeout(r, stepMs));
|
||||||
|
waited += stepMs;
|
||||||
|
}
|
||||||
|
waSessionActionStatus.textContent = "";
|
||||||
|
return latest;
|
||||||
|
};
|
||||||
|
const withWaSessionAction = async (fn, opts = {}) => {
|
||||||
|
waSessionActionStatus.textContent = "…";
|
||||||
|
try {
|
||||||
|
const resp = await fn();
|
||||||
|
const st = (resp && resp.status) || resp || {};
|
||||||
|
paintWaSession(st.ok === false && !st.lifecycle ? (await apiGet("/admin/api/whatsapp/session")) : st);
|
||||||
|
if (resp && resp.ok === false) {
|
||||||
|
waSessionActionStatus.textContent = String(resp.error || resp.stderr || "failed");
|
||||||
|
} else {
|
||||||
|
waSessionActionStatus.textContent = "ok";
|
||||||
|
}
|
||||||
|
if (opts.poll !== false && resp && resp.ok !== false) {
|
||||||
|
await pollWaSession(opts.poll || {});
|
||||||
|
}
|
||||||
|
return resp;
|
||||||
|
} catch (err) {
|
||||||
|
waSessionActionStatus.textContent = String(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const waSessionRefreshBtn = el("button", {
|
||||||
|
class: "btn",
|
||||||
|
text: t("wa.sessionRefresh"),
|
||||||
|
onclick: async () => {
|
||||||
|
await withWaSessionAction(async () => apiGet("/admin/api/whatsapp/session"), { poll: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const waSessionStartBtn = el("button", {
|
||||||
|
class: "btn",
|
||||||
|
text: t("wa.sessionStart"),
|
||||||
|
onclick: async () => {
|
||||||
|
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/start", {}));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const waSessionStopBtn = el("button", {
|
||||||
|
class: "btn",
|
||||||
|
text: t("wa.sessionStop"),
|
||||||
|
onclick: async () => {
|
||||||
|
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/stop", { force: true }), { poll: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const waSessionBindBtnClick = async () => {
|
||||||
|
const curLife = String((waSessionLatest && waSessionLatest.lifecycle) || "").trim();
|
||||||
|
const autoClear = ["needs_rebind", "logged_out"].includes(curLife);
|
||||||
|
const needClear = autoClear || (curLife && !["unbound", "awaiting_scan"].includes(curLife));
|
||||||
|
if (needClear && !autoClear && !window.confirm(t("wa.sessionRebindConfirm"))) return;
|
||||||
|
waSessionBindInProgress = true;
|
||||||
|
paintWaSession(waSessionLatest);
|
||||||
|
try {
|
||||||
|
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/bind", {
|
||||||
|
clear_auth: Boolean(needClear),
|
||||||
|
}), {
|
||||||
|
poll: {
|
||||||
|
maxMs: 120000,
|
||||||
|
stopWhen: (st) => {
|
||||||
|
const life = String((st && st.lifecycle) || "");
|
||||||
|
if (life === "online") return true;
|
||||||
|
return life === "awaiting_scan" && Boolean(st && (st.qr_data_url || st.qr));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
waSessionBindInProgress = false;
|
||||||
|
paintWaSession(waSessionLatest);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
waSessionBindBtn.onclick = waSessionBindBtnClick;
|
||||||
|
const waSessionUnbindBtn = el("button", {
|
||||||
|
class: "btn btn--danger",
|
||||||
|
text: t("wa.sessionUnbind"),
|
||||||
|
onclick: async () => {
|
||||||
|
if (!window.confirm(t("wa.sessionUnbindConfirm"))) return;
|
||||||
|
await withWaSessionAction(async () => apiPost("/admin/api/whatsapp/session/unbind", {}), { poll: false });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const whatsappSessionCard = el("div", { class: "card" }, [
|
||||||
|
el("div", { class: "card__head" }, [
|
||||||
|
el("div", { class: "card__headline" }, [
|
||||||
|
el("div", { class: "card__title", text: t("wa.sessionTitle") }),
|
||||||
|
el("div", { class: "card__subtitle", text: t("wa.sessionHint") }),
|
||||||
|
]),
|
||||||
|
el("div", { class: "card__actions" }, [
|
||||||
|
waSessionRefreshBtn,
|
||||||
|
waSessionStartBtn,
|
||||||
|
waSessionStopBtn,
|
||||||
|
waSessionBindBtn,
|
||||||
|
waSessionUnbindBtn,
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
waSessionStatus,
|
||||||
|
waSessionHintAlert,
|
||||||
|
waSessionDetail,
|
||||||
|
waSessionQrWrap,
|
||||||
|
waSessionActionStatus,
|
||||||
|
]);
|
||||||
|
paintWaSession(whatsappSessionResp || {});
|
||||||
|
if (whatsappSessionResp && whatsappSessionResp.sidecar_running) {
|
||||||
|
const initLife = String(whatsappSessionResp.lifecycle || "");
|
||||||
|
if (["connecting", "bound_offline"].includes(initLife)) {
|
||||||
|
void pollWaSession({ maxMs: 30000 });
|
||||||
|
}
|
||||||
|
}
|
||||||
const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : [];
|
const waGroups = Array.isArray(whatsappGroupsResp && whatsappGroupsResp.items) ? whatsappGroupsResp.items : [];
|
||||||
const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {};
|
const waBinding = (whatsappGroupsResp && whatsappGroupsResp.binding) || {};
|
||||||
const waGroupSel = el(
|
const waGroupSel = el(
|
||||||
|
|
@ -605,7 +807,7 @@ async function renderStack() {
|
||||||
}
|
}
|
||||||
prewarmStatus.textContent =
|
prewarmStatus.textContent =
|
||||||
t("auto.prewarm_running_in_background_refreshing_this_page_when_");
|
t("auto.prewarm_running_in_background_refreshing_this_page_when_");
|
||||||
runtimePrewarmReminder = "";
|
clearPrewarmReminder();
|
||||||
const maxWaitMs = 120000;
|
const maxWaitMs = 120000;
|
||||||
const stepMs = 400;
|
const stepMs = 400;
|
||||||
let waited = 0;
|
let waited = 0;
|
||||||
|
|
@ -696,6 +898,7 @@ async function renderStack() {
|
||||||
weixinDispatchCard,
|
weixinDispatchCard,
|
||||||
whatsappDispatchCard,
|
whatsappDispatchCard,
|
||||||
]),
|
]),
|
||||||
|
whatsappSessionCard,
|
||||||
whatsappAccessCard,
|
whatsappAccessCard,
|
||||||
whatsappAlertBindingCard,
|
whatsappAlertBindingCard,
|
||||||
el("div", { class: "card" }, [
|
el("div", { class: "card" }, [
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ openpyxl>=3.1.0
|
||||||
python-docx>=1.1.0
|
python-docx>=1.1.0
|
||||||
PyPDF2>=3.0.0
|
PyPDF2>=3.0.0
|
||||||
Pillow>=10.0.0
|
Pillow>=10.0.0
|
||||||
|
qrcode>=7.4.2
|
||||||
dashscope>=1.25.16
|
dashscope>=1.25.16
|
||||||
websocket-client>=1.8.0
|
websocket-client>=1.8.0
|
||||||
fastapi>=0.115.0
|
fastapi>=0.115.0
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ $bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
||||||
|
|
||||||
# Pin known-good versions to avoid breakage from upstream latest releases.
|
# Pin known-good versions to avoid breakage from upstream latest releases.
|
||||||
$baileysVersion = "7.0.0-rc.9"
|
$baileysVersion = "7.0.0-rc.9"
|
||||||
$qrcodeVersion = "0.12.0"
|
$qrcodeTerminalVersion = "0.12.0"
|
||||||
|
$qrcodeVersion = "1.5.4"
|
||||||
$httpsProxyAgentVersion = "7.0.6"
|
$httpsProxyAgentVersion = "7.0.6"
|
||||||
$tsxVersion = "4.20.3"
|
$tsxVersion = "4.20.3"
|
||||||
$typescriptVersion = "5.8.2"
|
$typescriptVersion = "5.8.2"
|
||||||
|
|
@ -38,7 +39,7 @@ try {
|
||||||
# Remove problematic package when present (seen on some environments with latest dependency graph).
|
# Remove problematic package when present (seen on some environments with latest dependency graph).
|
||||||
npm.cmd remove whatsapp-rust-bridge | Out-Null
|
npm.cmd remove whatsapp-rust-bridge | Out-Null
|
||||||
|
|
||||||
npm.cmd install --save --save-exact @whiskeysockets/baileys@$baileysVersion qrcode-terminal@$qrcodeVersion
|
npm.cmd install --save --save-exact @whiskeysockets/baileys@$baileysVersion qrcode-terminal@$qrcodeTerminalVersion qrcode@$qrcodeVersion
|
||||||
if ($LASTEXITCODE -ne 0) { throw "npm install deps failed with exit code $LASTEXITCODE" }
|
if ($LASTEXITCODE -ne 0) { throw "npm install deps failed with exit code $LASTEXITCODE" }
|
||||||
npm.cmd install --save --save-exact https-proxy-agent@$httpsProxyAgentVersion
|
npm.cmd install --save --save-exact https-proxy-agent@$httpsProxyAgentVersion
|
||||||
if ($LASTEXITCODE -ne 0) { throw "npm install proxy deps failed with exit code $LASTEXITCODE" }
|
if ($LASTEXITCODE -ne 0) { throw "npm install proxy deps failed with exit code $LASTEXITCODE" }
|
||||||
|
|
@ -49,7 +50,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Test-Path $bridgeSrc) {
|
if (Test-Path $bridgeSrc) {
|
||||||
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) {
|
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts", "status.ts")) {
|
||||||
$srcPath = Join-Path $bridgeSrc $name
|
$srcPath = Join-Path $bridgeSrc $name
|
||||||
if (Test-Path $srcPath) {
|
if (Test-Path $srcPath) {
|
||||||
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ if (-not (Test-Path (Join-Path $sidecarRoot "baileys_runner.ts"))) {
|
||||||
|
|
||||||
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
$bridgeSrc = Join-Path $oclawRoot "runtime\\operations\\whatsapp_bridge"
|
||||||
if (Test-Path $bridgeSrc) {
|
if (Test-Path $bridgeSrc) {
|
||||||
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts")) {
|
foreach ($name in @("baileys_runner.ts", "auth.ts", "qr.ts", "status.ts")) {
|
||||||
$srcPath = Join-Path $bridgeSrc $name
|
$srcPath = Join-Path $bridgeSrc $name
|
||||||
if (Test-Path $srcPath) {
|
if (Test-Path $srcPath) {
|
||||||
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
Copy-Item -Path $srcPath -Destination (Join-Path $sidecarRoot $name) -Force
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,8 @@ import makeWASocket, {
|
||||||
import { HttpsProxyAgent } from "https-proxy-agent";
|
import { HttpsProxyAgent } from "https-proxy-agent";
|
||||||
|
|
||||||
import { loadAuthState } from "./auth";
|
import { loadAuthState } from "./auth";
|
||||||
import { printQrToTerminal } from "./qr";
|
import { printQrToTerminal, writeQrImage } from "./qr";
|
||||||
|
import { writeBridgeStatus } from "./status";
|
||||||
|
|
||||||
type Json = Record<string, unknown>;
|
type Json = Record<string, unknown>;
|
||||||
|
|
||||||
|
|
@ -1118,6 +1119,18 @@ async function main(): Promise<void> {
|
||||||
const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined;
|
const wsAgent = PROXY_URL ? new HttpsProxyAgent(PROXY_URL) : undefined;
|
||||||
const groupNameCache = new Map<string, { name: string; ts: number }>();
|
const groupNameCache = new Map<string, { name: string; ts: number }>();
|
||||||
const GROUP_NAME_TTL_MS = 10 * 60 * 1000;
|
const GROUP_NAME_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const initialMe = String((state.creds as any)?.me?.id || "").trim();
|
||||||
|
writeBridgeStatus(STATE_DIR, {
|
||||||
|
connection: "connecting",
|
||||||
|
me: initialMe ? jidNormalizedUser(initialMe) : "",
|
||||||
|
phone: jidPhone(initialMe),
|
||||||
|
qr: "",
|
||||||
|
qr_data_url: "",
|
||||||
|
qr_png: "",
|
||||||
|
last_error: "",
|
||||||
|
reconnect_attempt: 0,
|
||||||
|
login_only: LOGIN_ONLY,
|
||||||
|
});
|
||||||
|
|
||||||
const resolveGroupName = async (chatId: string): Promise<string> => {
|
const resolveGroupName = async (chatId: string): Promise<string> => {
|
||||||
const key = String(chatId || "").trim();
|
const key = String(chatId || "").trim();
|
||||||
|
|
@ -1158,6 +1171,15 @@ async function main(): Promise<void> {
|
||||||
if (update.qr) {
|
if (update.qr) {
|
||||||
log("QR received. Scan it from WhatsApp -> Linked devices.");
|
log("QR received. Scan it from WhatsApp -> Linked devices.");
|
||||||
printQrToTerminal(update.qr);
|
printQrToTerminal(update.qr);
|
||||||
|
const qrArt = await writeQrImage(STATE_DIR, update.qr);
|
||||||
|
writeBridgeStatus(STATE_DIR, {
|
||||||
|
connection: "qr",
|
||||||
|
qr: String(update.qr || ""),
|
||||||
|
qr_data_url: qrArt.dataUrl,
|
||||||
|
qr_png: qrArt.pngPath ? path.basename(qrArt.pngPath) : "",
|
||||||
|
reconnect_attempt: reconnectAttempt,
|
||||||
|
login_only: LOGIN_ONLY,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (update.connection === "open") {
|
if (update.connection === "open") {
|
||||||
reconnectAttempt = 0;
|
reconnectAttempt = 0;
|
||||||
|
|
@ -1165,6 +1187,25 @@ async function main(): Promise<void> {
|
||||||
const botIds = collectBotIdentityJids(sock, sock?.user?.id ? String(sock.user.id) : "", state.creds);
|
const botIds = collectBotIdentityJids(sock, sock?.user?.id ? String(sock.user.id) : "", state.creds);
|
||||||
cachedBotIdentityJids = botIds;
|
cachedBotIdentityJids = botIds;
|
||||||
log(`connected. me=${me || "unknown"} botIds=${botIds.join(",") || "none"} loginOnly=${LOGIN_ONLY}`);
|
log(`connected. me=${me || "unknown"} botIds=${botIds.join(",") || "none"} loginOnly=${LOGIN_ONLY}`);
|
||||||
|
writeBridgeStatus(STATE_DIR, {
|
||||||
|
connection: "open",
|
||||||
|
me,
|
||||||
|
phone: jidPhone(me),
|
||||||
|
qr: "",
|
||||||
|
qr_data_url: "",
|
||||||
|
qr_png: "",
|
||||||
|
last_error: "",
|
||||||
|
last_disconnect_reason: "",
|
||||||
|
last_disconnect_status: null,
|
||||||
|
reconnect_attempt: 0,
|
||||||
|
login_only: LOGIN_ONLY,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const qrPng = path.join(STATE_DIR, "qr.png");
|
||||||
|
if (fs.existsSync(qrPng)) fs.unlinkSync(qrPng);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
if (LOGIN_ONLY) {
|
if (LOGIN_ONLY) {
|
||||||
log("login-only mode: exiting after successful link.");
|
log("login-only mode: exiting after successful link.");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|
@ -1177,9 +1218,42 @@ async function main(): Promise<void> {
|
||||||
log(`disconnected reason=${reason} statusCode=${String(statusCode || "")} err=${errText}`);
|
log(`disconnected reason=${reason} statusCode=${String(statusCode || "")} err=${errText}`);
|
||||||
if (statusCode === DisconnectReason.loggedOut) {
|
if (statusCode === DisconnectReason.loggedOut) {
|
||||||
log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link.");
|
log("logged out: delete data/channel_sidecar/whatsapp/state/auth to re-link.");
|
||||||
|
writeBridgeStatus(STATE_DIR, {
|
||||||
|
connection: "logged_out",
|
||||||
|
last_disconnect_reason: String(reason),
|
||||||
|
last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
|
||||||
|
last_error: errText,
|
||||||
|
reconnect_attempt: reconnectAttempt,
|
||||||
|
login_only: LOGIN_ONLY,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rebindCodes = new Set<number>([
|
||||||
|
DisconnectReason.forbidden,
|
||||||
|
DisconnectReason.badSession,
|
||||||
|
405,
|
||||||
|
]);
|
||||||
|
if (typeof statusCode === "number" && rebindCodes.has(statusCode)) {
|
||||||
|
log(`session invalid statusCode=${statusCode}: clear state/auth and scan QR again.`);
|
||||||
|
writeBridgeStatus(STATE_DIR, {
|
||||||
|
connection: "needs_rebind",
|
||||||
|
last_disconnect_reason: String(reason),
|
||||||
|
last_disconnect_status: statusCode,
|
||||||
|
last_error: errText,
|
||||||
|
reconnect_attempt: reconnectAttempt,
|
||||||
|
login_only: LOGIN_ONLY,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
reconnectAttempt += 1;
|
reconnectAttempt += 1;
|
||||||
|
writeBridgeStatus(STATE_DIR, {
|
||||||
|
connection: "close",
|
||||||
|
last_disconnect_reason: String(reason),
|
||||||
|
last_disconnect_status: typeof statusCode === "number" ? statusCode : null,
|
||||||
|
last_error: errText,
|
||||||
|
reconnect_attempt: reconnectAttempt,
|
||||||
|
login_only: LOGIN_ONLY,
|
||||||
|
});
|
||||||
const base = Math.min(30_000, 1000 * Math.pow(2, Math.min(6, reconnectAttempt)));
|
const base = Math.min(30_000, 1000 * Math.pow(2, Math.min(6, reconnectAttempt)));
|
||||||
const jitter = Math.floor(Math.random() * 500);
|
const jitter = Math.floor(Math.random() * 500);
|
||||||
const delay = base + jitter;
|
const delay = base + jitter;
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
import qrcode from "qrcode-terminal";
|
import qrcode from "qrcode-terminal";
|
||||||
|
|
||||||
export function printQrToTerminal(qr: string): void {
|
export function printQrToTerminal(qr: string): void {
|
||||||
|
|
@ -5,3 +7,20 @@ export function printQrToTerminal(qr: string): void {
|
||||||
qrcode.generate(qr, { small: true });
|
qrcode.generate(qr, { small: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function writeQrImage(stateDir: string, qr: string): Promise<{ pngPath: string; dataUrl: string }> {
|
||||||
|
const pngPath = path.join(stateDir, "qr.png");
|
||||||
|
try {
|
||||||
|
// Optional dependency: installed by whatsapp_install.ps1 when available.
|
||||||
|
const QRCode = await import("qrcode");
|
||||||
|
await QRCode.toFile(pngPath, qr, { width: 320, margin: 1, errorCorrectionLevel: "M" });
|
||||||
|
const dataUrl = await QRCode.toDataURL(qr, { width: 320, margin: 1, errorCorrectionLevel: "M" });
|
||||||
|
return { pngPath, dataUrl: String(dataUrl || "") };
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(pngPath)) fs.unlinkSync(pngPath);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { pngPath: "", dataUrl: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
61
runtime/operations/whatsapp_bridge/status.ts
Normal file
61
runtime/operations/whatsapp_bridge/status.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export type BridgeStatus = {
|
||||||
|
connection: "connecting" | "qr" | "open" | "close" | "logged_out" | "needs_rebind" | "stopped";
|
||||||
|
me: string;
|
||||||
|
phone: string;
|
||||||
|
qr: string;
|
||||||
|
qr_data_url: string;
|
||||||
|
qr_png: string;
|
||||||
|
last_disconnect_reason: string;
|
||||||
|
last_disconnect_status: number | null;
|
||||||
|
last_error: string;
|
||||||
|
reconnect_attempt: number;
|
||||||
|
login_only: boolean;
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_FILE = "bridge_status.json";
|
||||||
|
|
||||||
|
function phoneFromMe(me: string): string {
|
||||||
|
const head = String(me || "").split("@")[0]?.split(":")[0] || "";
|
||||||
|
return head.replace(/\D/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function statusPath(stateDir: string): string {
|
||||||
|
return path.join(stateDir, STATUS_FILE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeBridgeStatus(stateDir: string, patch: Partial<BridgeStatus>): BridgeStatus {
|
||||||
|
fs.mkdirSync(stateDir, { recursive: true });
|
||||||
|
const p = statusPath(stateDir);
|
||||||
|
let prev: Partial<BridgeStatus> = {};
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(p)) {
|
||||||
|
prev = JSON.parse(fs.readFileSync(p, "utf8")) as Partial<BridgeStatus>;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
prev = {};
|
||||||
|
}
|
||||||
|
const me = String(patch.me ?? prev.me ?? "").trim();
|
||||||
|
const next: BridgeStatus = {
|
||||||
|
connection: (patch.connection || prev.connection || "connecting") as BridgeStatus["connection"],
|
||||||
|
me,
|
||||||
|
phone: String(patch.phone ?? prev.phone ?? phoneFromMe(me)).trim(),
|
||||||
|
qr: String(patch.qr ?? prev.qr ?? ""),
|
||||||
|
qr_data_url: String(patch.qr_data_url ?? prev.qr_data_url ?? ""),
|
||||||
|
qr_png: String(patch.qr_png ?? prev.qr_png ?? ""),
|
||||||
|
last_disconnect_reason: String(patch.last_disconnect_reason ?? prev.last_disconnect_reason ?? ""),
|
||||||
|
last_disconnect_status:
|
||||||
|
patch.last_disconnect_status === undefined
|
||||||
|
? (typeof prev.last_disconnect_status === "number" ? prev.last_disconnect_status : null)
|
||||||
|
: patch.last_disconnect_status,
|
||||||
|
last_error: String(patch.last_error ?? prev.last_error ?? ""),
|
||||||
|
reconnect_attempt: Number(patch.reconnect_attempt ?? prev.reconnect_attempt ?? 0) || 0,
|
||||||
|
login_only: Boolean(patch.login_only ?? prev.login_only ?? false),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(p, JSON.stringify(next, null, 2), "utf8");
|
||||||
|
return next;
|
||||||
|
}
|
||||||
424
runtime/operations/whatsapp_sidecar.py
Normal file
424
runtime/operations/whatsapp_sidecar.py
Normal file
|
|
@ -0,0 +1,424 @@
|
||||||
|
"""WhatsApp Baileys sidecar control helpers for admin / ops."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from runtime.operations.runtime import is_pid_running
|
||||||
|
from svc.config.log_paths import oclaw_log_root
|
||||||
|
from svc.config.paths import PROJECT_ROOT
|
||||||
|
|
||||||
|
DEFAULT_CHANNEL_ID = "whatsapp"
|
||||||
|
DEFAULT_ACCOUNT_ID = "wa-default"
|
||||||
|
DEFAULT_GATEWAY_BASE_URL = "http://127.0.0.1:8787"
|
||||||
|
|
||||||
|
|
||||||
|
def sidecar_root(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||||
|
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||||
|
return (PROJECT_ROOT / "data" / "channel_sidecar" / cid).resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def state_dir(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||||
|
return sidecar_root(channel_id) / "state"
|
||||||
|
|
||||||
|
|
||||||
|
def auth_dir(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||||
|
return state_dir(channel_id) / "auth"
|
||||||
|
|
||||||
|
|
||||||
|
def pid_file(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||||
|
return sidecar_root(channel_id) / "pid.txt"
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_status_file(channel_id: str = DEFAULT_CHANNEL_ID) -> Path:
|
||||||
|
return state_dir(channel_id) / "bridge_status.json"
|
||||||
|
|
||||||
|
|
||||||
|
def scripts_dir() -> Path:
|
||||||
|
return (PROJECT_ROOT / "runtime" / "operations" / "scripts").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def bridge_src_dir() -> Path:
|
||||||
|
return (PROJECT_ROOT / "runtime" / "operations" / "whatsapp_bridge").resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pid(channel_id: str = DEFAULT_CHANNEL_ID) -> int:
|
||||||
|
p = pid_file(channel_id)
|
||||||
|
if not p.exists():
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
raw = (p.read_text(encoding="utf-8", errors="replace").strip().splitlines() or [""])[0].strip()
|
||||||
|
return int(raw) if raw.isdigit() else 0
|
||||||
|
except Exception:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_local_status(channel_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
path = bridge_status_file(channel_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
prev: dict[str, Any] = {}
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
prev = obj
|
||||||
|
except Exception:
|
||||||
|
prev = {}
|
||||||
|
next_obj = dict(prev)
|
||||||
|
next_obj.update(patch or {})
|
||||||
|
next_obj["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||||
|
path.write_text(json.dumps(next_obj, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
return next_obj
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bridge_status(channel_id: str = DEFAULT_CHANNEL_ID) -> dict[str, Any]:
|
||||||
|
path = bridge_status_file(channel_id)
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
obj = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
return obj if isinstance(obj, dict) else {}
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_bound_me(channel_id: str = DEFAULT_CHANNEL_ID) -> dict[str, str]:
|
||||||
|
creds = auth_dir(channel_id) / "creds.json"
|
||||||
|
if not creds.exists():
|
||||||
|
return {"me": "", "phone": "", "name": ""}
|
||||||
|
try:
|
||||||
|
obj = json.loads(creds.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return {"me": "", "phone": "", "name": ""}
|
||||||
|
me_obj = obj.get("me") if isinstance(obj, dict) else None
|
||||||
|
if not isinstance(me_obj, dict):
|
||||||
|
return {"me": "", "phone": "", "name": ""}
|
||||||
|
me = str(me_obj.get("id") or "").strip()
|
||||||
|
name = str(me_obj.get("name") or "").strip()
|
||||||
|
phone = me.split("@")[0].split(":")[0]
|
||||||
|
phone = "".join(ch for ch in phone if ch.isdigit())
|
||||||
|
return {"me": me, "phone": phone, "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_bridge_sources(channel_id: str = DEFAULT_CHANNEL_ID) -> None:
|
||||||
|
root = sidecar_root(channel_id)
|
||||||
|
src = bridge_src_dir()
|
||||||
|
if not src.exists() or not root.exists():
|
||||||
|
return
|
||||||
|
for name in ("baileys_runner.ts", "auth.ts", "qr.ts", "status.ts"):
|
||||||
|
sp = src / name
|
||||||
|
if sp.exists():
|
||||||
|
shutil.copy2(sp, root / name)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ps1(script_name: str, args: list[str] | None = None, *, timeout_s: float = 120.0) -> dict[str, Any]:
|
||||||
|
script = scripts_dir() / script_name
|
||||||
|
if not script.exists():
|
||||||
|
return {"ok": False, "error": f"script_missing:{script_name}"}
|
||||||
|
cmd = [
|
||||||
|
"powershell",
|
||||||
|
"-NoProfile",
|
||||||
|
"-ExecutionPolicy",
|
||||||
|
"Bypass",
|
||||||
|
"-File",
|
||||||
|
str(script),
|
||||||
|
*(args or []),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
cp = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
cwd=str(PROJECT_ROOT),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=max(5.0, float(timeout_s)),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"error": "timeout",
|
||||||
|
"stdout": str(exc.stdout or ""),
|
||||||
|
"stderr": str(exc.stderr or ""),
|
||||||
|
}
|
||||||
|
except Exception as exc:
|
||||||
|
return {"ok": False, "error": str(exc)}
|
||||||
|
out = (cp.stdout or "").strip()
|
||||||
|
err = (cp.stderr or "").strip()
|
||||||
|
return {
|
||||||
|
"ok": int(cp.returncode or 0) == 0,
|
||||||
|
"returncode": int(cp.returncode or 0),
|
||||||
|
"stdout": out[-4000:],
|
||||||
|
"stderr": err[-4000:],
|
||||||
|
"error": "" if int(cp.returncode or 0) == 0 else (err or out or f"exit_{cp.returncode}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def installed(channel_id: str = DEFAULT_CHANNEL_ID) -> bool:
|
||||||
|
return (sidecar_root(channel_id) / "baileys_runner.ts").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _needs_rebind_disconnect(bridge: dict[str, Any]) -> bool:
|
||||||
|
code = bridge.get("last_disconnect_status")
|
||||||
|
try:
|
||||||
|
n = int(code) if code is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
n = None
|
||||||
|
if n in (403, 405, 500):
|
||||||
|
return True
|
||||||
|
err = str(bridge.get("last_error") or "").lower()
|
||||||
|
return n == 405 and "connection failure" in err
|
||||||
|
|
||||||
|
|
||||||
|
def _qr_to_data_url(qr_text: str) -> str:
|
||||||
|
text = str(qr_text or "").strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
img = qrcode.make(text)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format="PNG")
|
||||||
|
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def session_status(
|
||||||
|
*,
|
||||||
|
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||||
|
account_id: str = DEFAULT_ACCOUNT_ID,
|
||||||
|
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||||
|
aid = str(account_id or os.getenv("AIA_WHATSAPP_ACCOUNT_ID") or DEFAULT_ACCOUNT_ID).strip() or DEFAULT_ACCOUNT_ID
|
||||||
|
pid = _read_pid(cid)
|
||||||
|
running = bool(pid and is_pid_running(pid))
|
||||||
|
bound_info = _read_bound_me(cid)
|
||||||
|
bridge = _read_bridge_status(cid)
|
||||||
|
auth_exists = auth_dir(cid).exists() and any(auth_dir(cid).glob("*.json"))
|
||||||
|
bound = bool(bound_info.get("me") or auth_exists)
|
||||||
|
connection = str(bridge.get("connection") or "").strip().lower()
|
||||||
|
if not running:
|
||||||
|
if bound:
|
||||||
|
lifecycle = "bound_stopped"
|
||||||
|
else:
|
||||||
|
lifecycle = "unbound"
|
||||||
|
connection = "stopped"
|
||||||
|
else:
|
||||||
|
if connection == "open":
|
||||||
|
lifecycle = "online"
|
||||||
|
elif connection == "qr":
|
||||||
|
lifecycle = "awaiting_scan"
|
||||||
|
elif connection == "connecting":
|
||||||
|
lifecycle = "connecting"
|
||||||
|
elif connection == "logged_out":
|
||||||
|
lifecycle = "logged_out"
|
||||||
|
elif connection == "needs_rebind" or _needs_rebind_disconnect(bridge):
|
||||||
|
lifecycle = "needs_rebind"
|
||||||
|
elif bound:
|
||||||
|
lifecycle = "bound_offline"
|
||||||
|
if not connection:
|
||||||
|
connection = "close"
|
||||||
|
else:
|
||||||
|
lifecycle = "connecting"
|
||||||
|
if not connection:
|
||||||
|
connection = "connecting"
|
||||||
|
me = str(bridge.get("me") or bound_info.get("me") or "").strip()
|
||||||
|
phone = str(bridge.get("phone") or bound_info.get("phone") or "").strip()
|
||||||
|
name = str(bound_info.get("name") or "").strip()
|
||||||
|
qr = str(bridge.get("qr") or "").strip()
|
||||||
|
qr_data_url = str(bridge.get("qr_data_url") or "").strip()
|
||||||
|
qr_png_name = str(bridge.get("qr_png") or "").strip()
|
||||||
|
qr_png_path = state_dir(cid) / qr_png_name if qr_png_name else state_dir(cid) / "qr.png"
|
||||||
|
if (not qr_data_url) and qr_png_path.exists():
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
raw = qr_png_path.read_bytes()
|
||||||
|
qr_data_url = "data:image/png;base64," + base64.b64encode(raw).decode("ascii")
|
||||||
|
except Exception:
|
||||||
|
qr_data_url = ""
|
||||||
|
if (not qr_data_url) and qr:
|
||||||
|
qr_data_url = _qr_to_data_url(qr)
|
||||||
|
stale_qr = False
|
||||||
|
if qr or qr_data_url:
|
||||||
|
try:
|
||||||
|
updated = str(bridge.get("updated_at") or "").strip()
|
||||||
|
if updated:
|
||||||
|
# QR older than 2 minutes is usually expired.
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
ts = datetime.fromisoformat(updated.replace("Z", "+00:00")).timestamp()
|
||||||
|
stale_qr = (time.time() - ts) > 120
|
||||||
|
except Exception:
|
||||||
|
stale_qr = False
|
||||||
|
log_path = oclaw_log_root() / "whatsapp_sidecar.log"
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"channel_id": cid,
|
||||||
|
"account_id": aid,
|
||||||
|
"gateway_base_url": str(gateway_base_url or DEFAULT_GATEWAY_BASE_URL).rstrip("/"),
|
||||||
|
"installed": installed(cid),
|
||||||
|
"sidecar_running": running,
|
||||||
|
"pid": int(pid or 0) if running else 0,
|
||||||
|
"bound": bound,
|
||||||
|
"lifecycle": lifecycle,
|
||||||
|
"connection": connection or ("stopped" if not running else "unknown"),
|
||||||
|
"me": me,
|
||||||
|
"phone": phone,
|
||||||
|
"display_name": name,
|
||||||
|
"qr": qr if lifecycle == "awaiting_scan" else "",
|
||||||
|
"qr_data_url": qr_data_url if lifecycle == "awaiting_scan" else "",
|
||||||
|
"qr_stale": bool(stale_qr) if lifecycle == "awaiting_scan" else False,
|
||||||
|
"last_disconnect_reason": str(bridge.get("last_disconnect_reason") or ""),
|
||||||
|
"last_disconnect_status": bridge.get("last_disconnect_status"),
|
||||||
|
"last_error": str(bridge.get("last_error") or ""),
|
||||||
|
"reconnect_attempt": int(bridge.get("reconnect_attempt") or 0),
|
||||||
|
"bridge_updated_at": str(bridge.get("updated_at") or ""),
|
||||||
|
"auth_dir": str(auth_dir(cid)),
|
||||||
|
"log_path": str(log_path),
|
||||||
|
"status_hint": {
|
||||||
|
"unbound": "not_bound",
|
||||||
|
"bound_stopped": "bound_but_sidecar_stopped",
|
||||||
|
"bound_offline": "bound_but_offline",
|
||||||
|
"online": "online",
|
||||||
|
"awaiting_scan": "scan_qr",
|
||||||
|
"logged_out": "logged_out_need_rebind",
|
||||||
|
"needs_rebind": "session_invalid_need_rebind",
|
||||||
|
"connecting": "connecting",
|
||||||
|
}.get(lifecycle, lifecycle),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def stop_sidecar(channel_id: str = DEFAULT_CHANNEL_ID, *, force: bool = True) -> dict[str, Any]:
|
||||||
|
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||||
|
args = ["-ChannelId", cid]
|
||||||
|
if force:
|
||||||
|
args.append("-Force")
|
||||||
|
result = _run_ps1("whatsapp_stop.ps1", args, timeout_s=60.0)
|
||||||
|
_write_local_status(
|
||||||
|
cid,
|
||||||
|
{
|
||||||
|
"connection": "stopped",
|
||||||
|
"qr": "",
|
||||||
|
"qr_data_url": "",
|
||||||
|
"qr_png": "",
|
||||||
|
"last_error": "",
|
||||||
|
"reconnect_attempt": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"ok": bool(result.get("ok")), "action": "stop", **result}
|
||||||
|
|
||||||
|
|
||||||
|
def start_sidecar(
|
||||||
|
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||||
|
*,
|
||||||
|
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||||
|
if not installed(cid):
|
||||||
|
return {"ok": False, "error": "whatsapp_sidecar_not_installed", "action": "start"}
|
||||||
|
_sync_bridge_sources(cid)
|
||||||
|
# Avoid duplicate processes.
|
||||||
|
cur = session_status(channel_id=cid, gateway_base_url=gateway_base_url)
|
||||||
|
if cur.get("sidecar_running"):
|
||||||
|
return {"ok": True, "action": "start", "already_running": True, "status": cur}
|
||||||
|
base = str(gateway_base_url or DEFAULT_GATEWAY_BASE_URL).rstrip("/") or DEFAULT_GATEWAY_BASE_URL
|
||||||
|
result = _run_ps1(
|
||||||
|
"whatsapp_start.ps1",
|
||||||
|
["-ChannelId", cid, "-GatewayBaseUrl", base],
|
||||||
|
timeout_s=90.0,
|
||||||
|
)
|
||||||
|
# Give runner a moment to write status.
|
||||||
|
time.sleep(1.0)
|
||||||
|
status = session_status(channel_id=cid, gateway_base_url=base)
|
||||||
|
return {"ok": bool(result.get("ok")), "action": "start", "status": status, **result}
|
||||||
|
|
||||||
|
|
||||||
|
def unbind_session(
|
||||||
|
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||||
|
*,
|
||||||
|
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Stop sidecar and clear device auth so a fresh QR bind is required."""
|
||||||
|
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||||
|
stop = stop_sidecar(cid, force=True)
|
||||||
|
auth = auth_dir(cid)
|
||||||
|
removed_auth = False
|
||||||
|
if auth.exists():
|
||||||
|
shutil.rmtree(auth, ignore_errors=True)
|
||||||
|
removed_auth = not auth.exists()
|
||||||
|
for extra in (state_dir(cid) / "qr.png", bridge_status_file(cid)):
|
||||||
|
try:
|
||||||
|
if extra.exists():
|
||||||
|
extra.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_write_local_status(
|
||||||
|
cid,
|
||||||
|
{
|
||||||
|
"connection": "stopped",
|
||||||
|
"me": "",
|
||||||
|
"phone": "",
|
||||||
|
"qr": "",
|
||||||
|
"qr_data_url": "",
|
||||||
|
"qr_png": "",
|
||||||
|
"last_disconnect_reason": "",
|
||||||
|
"last_disconnect_status": None,
|
||||||
|
"last_error": "",
|
||||||
|
"reconnect_attempt": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
status = session_status(channel_id=cid, gateway_base_url=gateway_base_url)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"action": "unbind",
|
||||||
|
"removed_auth": removed_auth,
|
||||||
|
"stop": stop,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def start_bind(
|
||||||
|
channel_id: str = DEFAULT_CHANNEL_ID,
|
||||||
|
*,
|
||||||
|
gateway_base_url: str = DEFAULT_GATEWAY_BASE_URL,
|
||||||
|
clear_auth: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Ensure sidecar is running so a QR can appear (optionally after clearing auth)."""
|
||||||
|
cid = str(channel_id or DEFAULT_CHANNEL_ID).strip() or DEFAULT_CHANNEL_ID
|
||||||
|
base = str(gateway_base_url or DEFAULT_GATEWAY_BASE_URL).rstrip("/") or DEFAULT_GATEWAY_BASE_URL
|
||||||
|
if clear_auth:
|
||||||
|
unbind_session(cid, gateway_base_url=base)
|
||||||
|
else:
|
||||||
|
stop_sidecar(cid, force=True)
|
||||||
|
started = start_sidecar(cid, gateway_base_url=base)
|
||||||
|
# Poll for QR / online (fresh sidecar may take 10-40s to emit QR).
|
||||||
|
status = started.get("status") if isinstance(started.get("status"), dict) else session_status(channel_id=cid, gateway_base_url=base)
|
||||||
|
for _ in range(45):
|
||||||
|
life = str(status.get("lifecycle") or "")
|
||||||
|
if life == "online":
|
||||||
|
break
|
||||||
|
if life == "awaiting_scan" and (status.get("qr_data_url") or status.get("qr")):
|
||||||
|
break
|
||||||
|
time.sleep(1.0)
|
||||||
|
status = session_status(channel_id=cid, gateway_base_url=base)
|
||||||
|
return {
|
||||||
|
"ok": bool(started.get("ok")),
|
||||||
|
"action": "bind",
|
||||||
|
"clear_auth": bool(clear_auth),
|
||||||
|
"start": started,
|
||||||
|
"status": status,
|
||||||
|
}
|
||||||
139
tests/test_whatsapp_admin_session.py
Normal file
139
tests/test_whatsapp_admin_session.py
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from runtime.operations import whatsapp_sidecar as wa
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_whatsapp_bridge_writes_status_module() -> None:
|
||||||
|
text = (REPO_ROOT / "runtime/operations/whatsapp_bridge/baileys_runner.ts").read_text(encoding="utf-8")
|
||||||
|
assert "writeBridgeStatus" in text
|
||||||
|
assert 'from "./status"' in text
|
||||||
|
assert "writeQrImage" in text
|
||||||
|
status = (REPO_ROOT / "runtime/operations/whatsapp_bridge/status.ts").read_text(encoding="utf-8")
|
||||||
|
assert "bridge_status.json" in status
|
||||||
|
|
||||||
|
|
||||||
|
def test_whatsapp_install_copies_status_and_qrcode_pkg() -> None:
|
||||||
|
text = (REPO_ROOT / "runtime/operations/scripts/whatsapp_install.ps1").read_text(encoding="utf-8")
|
||||||
|
assert "status.ts" in text
|
||||||
|
assert "qrcode@$qrcodeVersion" in text
|
||||||
|
start = (REPO_ROOT / "runtime/operations/scripts/whatsapp_start.ps1").read_text(encoding="utf-8")
|
||||||
|
assert "status.ts" in start
|
||||||
|
|
||||||
|
|
||||||
|
def test_whatsapp_session_status_unbound(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||||
|
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||||
|
root.mkdir(parents=True)
|
||||||
|
(root / "baileys_runner.ts").write_text("// stub\n", encoding="utf-8")
|
||||||
|
st = wa.session_status(channel_id="whatsapp")
|
||||||
|
assert st["ok"] is True
|
||||||
|
assert st["installed"] is True
|
||||||
|
assert st["bound"] is False
|
||||||
|
assert st["lifecycle"] == "unbound"
|
||||||
|
assert st["sidecar_running"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_whatsapp_session_status_bound_offline(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(wa, "is_pid_running", lambda pid: True)
|
||||||
|
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||||
|
state = root / "state"
|
||||||
|
auth = state / "auth"
|
||||||
|
auth.mkdir(parents=True)
|
||||||
|
(root / "baileys_runner.ts").write_text("// stub\n", encoding="utf-8")
|
||||||
|
(root / "pid.txt").write_text("4242\n", encoding="utf-8")
|
||||||
|
(auth / "creds.json").write_text(
|
||||||
|
json.dumps({"me": {"id": "8615601877957:4@s.whatsapp.net", "name": "oclaw"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(state / "bridge_status.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"connection": "close",
|
||||||
|
"me": "8615601877957:4@s.whatsapp.net",
|
||||||
|
"phone": "8615601877957",
|
||||||
|
"reconnect_attempt": 12,
|
||||||
|
"last_error": "Connection was lost",
|
||||||
|
"updated_at": "2026-08-13T09:00:00Z",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
st = wa.session_status(channel_id="whatsapp")
|
||||||
|
assert st["bound"] is True
|
||||||
|
assert st["sidecar_running"] is True
|
||||||
|
assert st["lifecycle"] == "bound_offline"
|
||||||
|
assert st["phone"] == "8615601877957"
|
||||||
|
assert st["reconnect_attempt"] == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_whatsapp_unbind_removes_auth(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(wa, "stop_sidecar", lambda channel_id="whatsapp", force=True: {"ok": True, "action": "stop"})
|
||||||
|
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||||
|
auth = root / "state" / "auth"
|
||||||
|
auth.mkdir(parents=True)
|
||||||
|
(auth / "creds.json").write_text("{}", encoding="utf-8")
|
||||||
|
(root / "state" / "qr.png").write_bytes(b"png")
|
||||||
|
out = wa.unbind_session(channel_id="whatsapp")
|
||||||
|
assert out["ok"] is True
|
||||||
|
assert out["removed_auth"] is True
|
||||||
|
assert not auth.exists()
|
||||||
|
assert out["status"]["lifecycle"] == "unbound"
|
||||||
|
|
||||||
|
|
||||||
|
def test_whatsapp_session_status_needs_rebind_on_405(tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setattr(wa, "PROJECT_ROOT", tmp_path)
|
||||||
|
monkeypatch.setattr(wa, "is_pid_running", lambda pid: True)
|
||||||
|
root = tmp_path / "data" / "channel_sidecar" / "whatsapp"
|
||||||
|
state = root / "state"
|
||||||
|
auth = state / "auth"
|
||||||
|
auth.mkdir(parents=True)
|
||||||
|
(root / "baileys_runner.ts").write_text("// stub\n", encoding="utf-8")
|
||||||
|
(root / "pid.txt").write_text("4242\n", encoding="utf-8")
|
||||||
|
(auth / "creds.json").write_text(
|
||||||
|
json.dumps({"me": {"id": "8615601877957:4@s.whatsapp.net", "name": "oclaw"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(state / "bridge_status.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"connection": "close",
|
||||||
|
"me": "8615601877957@s.whatsapp.net",
|
||||||
|
"phone": "8615601877957",
|
||||||
|
"last_disconnect_status": 405,
|
||||||
|
"last_error": "Error: Connection Failure",
|
||||||
|
"updated_at": "2026-08-17T13:50:57.152Z",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
st = wa.session_status(channel_id="whatsapp")
|
||||||
|
assert st["lifecycle"] == "needs_rebind"
|
||||||
|
assert st["status_hint"] == "session_invalid_need_rebind"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qr_to_data_url_generates_png() -> None:
|
||||||
|
url = wa._qr_to_data_url("2@testqr")
|
||||||
|
assert url.startswith("data:image/png;base64,")
|
||||||
|
|
||||||
|
|
||||||
|
def test_admin_routes_expose_whatsapp_session_apis() -> None:
|
||||||
|
text = (REPO_ROOT / "interfaces/admin/routes.py").read_text(encoding="utf-8")
|
||||||
|
assert '/admin/api/whatsapp/session"' in text
|
||||||
|
assert "/admin/api/whatsapp/session/start" in text
|
||||||
|
assert "/admin/api/whatsapp/session/stop" in text
|
||||||
|
assert "/admin/api/whatsapp/session/unbind" in text
|
||||||
|
assert "/admin/api/whatsapp/session/bind" in text
|
||||||
|
stack = (REPO_ROOT / "interfaces/admin/static/js/pages/stack.js").read_text(encoding="utf-8")
|
||||||
|
assert "/admin/api/whatsapp/session" in stack
|
||||||
|
assert "whatsappSessionCard" in stack
|
||||||
|
assert "pollWaSession" in stack
|
||||||
|
runner = (REPO_ROOT / "runtime/operations/whatsapp_bridge/baileys_runner.ts").read_text(encoding="utf-8")
|
||||||
|
assert "needs_rebind" in runner
|
||||||
Loading…
Add table
Add a link
Reference in a new issue