feat(whatsapp): add access control with quote-based admin approval

Introduce whitelist/blacklist gating, pending requests, Admin UI management, and stanza-mapped quote approval so admins can approve via WhatsApp DM without blocking normal LLM chat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-01 11:36:58 +08:00
parent 8781a200c9
commit 1ea13a205d
13 changed files with 3048 additions and 30 deletions

View file

@ -658,6 +658,72 @@ class SqliteStore(ScheduledJobStoreMixin):
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_config (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
access_mode TEXT NOT NULL DEFAULT 'blacklist',
lang TEXT NOT NULL DEFAULT 'en',
updated_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id)
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_contact (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
list_type TEXT,
notes TEXT NOT NULL DEFAULT '',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, external_user_id)
);
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
request_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
resolved_at TEXT,
resolved_by TEXT NOT NULL DEFAULT ''
);
"""
)
self._ensure_whatsapp_access_pending_phone_column(conn)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_access_pending_status ON whatsapp_access_pending(tenant_id, account_id, status, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending_notify (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
admin_chat_id TEXT NOT NULL,
notify_stanza_id TEXT NOT NULL,
pending_id TEXT NOT NULL,
notify_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, admin_chat_id, notify_stanza_id)
);
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_pending_notify_pending ON whatsapp_access_pending_notify(pending_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS channel_outbound_message (
@ -1248,6 +1314,72 @@ class SqliteStore(ScheduledJobStoreMixin):
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_config (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
access_mode TEXT NOT NULL DEFAULT 'blacklist',
lang TEXT NOT NULL DEFAULT 'en',
updated_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_contact (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
list_type TEXT,
notes TEXT NOT NULL DEFAULT '',
first_seen_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, external_user_id)
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
external_user_id TEXT NOT NULL,
push_name TEXT NOT NULL DEFAULT '',
phone TEXT NOT NULL DEFAULT '',
request_text TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT NOT NULL,
resolved_at TEXT,
resolved_by TEXT NOT NULL DEFAULT ''
)
"""
)
self._ensure_whatsapp_access_pending_phone_column(conn)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_access_pending_status ON whatsapp_access_pending(tenant_id, account_id, status, created_at)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS whatsapp_access_pending_notify (
tenant_id TEXT NOT NULL,
account_id TEXT NOT NULL,
admin_chat_id TEXT NOT NULL,
notify_stanza_id TEXT NOT NULL,
pending_id TEXT NOT NULL,
notify_text TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
PRIMARY KEY (tenant_id, account_id, admin_chat_id, notify_stanza_id)
);
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_whatsapp_pending_notify_pending ON whatsapp_access_pending_notify(pending_id)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS channel_outbound_message (
@ -5583,6 +5715,621 @@ class SqliteStore(ScheduledJobStoreMixin):
out = self.get_whatsapp_alert_binding(tenant_id=tenant_id, account_id=account_id)
return out or {}
def get_whatsapp_access_config(
self,
*,
tenant_id: str,
account_id: str,
) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, account_id, access_mode, lang, updated_at
FROM whatsapp_access_config
WHERE tenant_id = ? AND account_id = ?
""",
(str(tenant_id), str(account_id)),
).fetchone()
if not row:
return None
return {
"tenant_id": str(row["tenant_id"] or ""),
"account_id": str(row["account_id"] or ""),
"access_mode": str(row["access_mode"] or "blacklist"),
"lang": str(row["lang"] or "en"),
"updated_at": str(row["updated_at"] or ""),
}
def upsert_whatsapp_access_config(
self,
*,
tenant_id: str,
account_id: str,
access_mode: str,
lang: str = "en",
) -> dict[str, Any]:
ts = utc_now_iso()
mode = str(access_mode or "blacklist").strip().lower()
if mode not in {"blacklist", "whitelist"}:
mode = "blacklist"
lang_val = str(lang or "en").strip().lower() or "en"
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_access_config (tenant_id, account_id, access_mode, lang, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, account_id) DO UPDATE SET
access_mode = excluded.access_mode,
lang = excluded.lang,
updated_at = excluded.updated_at
""",
(str(tenant_id), str(account_id), mode, lang_val, ts),
)
return self.get_whatsapp_access_config(tenant_id=tenant_id, account_id=account_id) or {}
def upsert_whatsapp_contact(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
push_name: str = "",
phone: str = "",
list_type: str | None = None,
notes: str = "",
) -> dict[str, Any]:
ts = utc_now_iso()
lt = str(list_type).strip().lower() if list_type is not None else None
if lt is not None and lt not in {"admin", "whitelist", "blacklist"}:
lt = None
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_contact
(tenant_id, account_id, external_user_id, push_name, phone, list_type, notes, first_seen_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, account_id, external_user_id) DO UPDATE SET
push_name = CASE WHEN excluded.push_name != '' THEN excluded.push_name ELSE whatsapp_contact.push_name END,
phone = CASE WHEN excluded.phone != '' THEN excluded.phone ELSE whatsapp_contact.phone END,
list_type = COALESCE(excluded.list_type, whatsapp_contact.list_type),
notes = CASE WHEN excluded.notes != '' THEN excluded.notes ELSE whatsapp_contact.notes END,
last_seen_at = excluded.last_seen_at
""",
(
str(tenant_id),
str(account_id),
str(external_user_id),
str(push_name or ""),
str(phone or ""),
lt,
str(notes or ""),
ts,
ts,
),
)
return self.get_whatsapp_contact(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=external_user_id,
) or {}
def get_whatsapp_contact(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
) -> dict[str, Any] | None:
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, account_id, external_user_id, push_name, phone, list_type, notes, first_seen_at, last_seen_at
FROM whatsapp_contact
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(tenant_id), str(account_id), str(external_user_id)),
).fetchone()
if not row:
return None
return {
"tenant_id": str(row["tenant_id"] or ""),
"account_id": str(row["account_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
"push_name": str(row["push_name"] or ""),
"phone": str(row["phone"] or ""),
"list_type": str(row["list_type"] or "") or None,
"notes": str(row["notes"] or ""),
"first_seen_at": str(row["first_seen_at"] or ""),
"last_seen_at": str(row["last_seen_at"] or ""),
}
def list_whatsapp_contacts(
self,
*,
tenant_id: str,
account_id: str,
list_type: str | None = None,
limit: int = 200,
) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 500))
clauses = ["tenant_id = ?", "account_id = ?"]
params: list[Any] = [str(tenant_id), str(account_id)]
if list_type:
clauses.append("list_type = ?")
params.append(str(list_type))
where = " AND ".join(clauses)
with self._connect() as conn:
rows = conn.execute(
f"""
SELECT tenant_id, account_id, external_user_id, push_name, phone, list_type, notes, first_seen_at, last_seen_at
FROM whatsapp_contact
WHERE {where}
ORDER BY last_seen_at DESC
LIMIT ?
""",
(*params, lim),
).fetchall()
return [
{
"tenant_id": str(r["tenant_id"] or ""),
"account_id": str(r["account_id"] or ""),
"external_user_id": str(r["external_user_id"] or ""),
"push_name": str(r["push_name"] or ""),
"phone": str(r["phone"] or ""),
"list_type": str(r["list_type"] or "") or None,
"notes": str(r["notes"] or ""),
"first_seen_at": str(r["first_seen_at"] or ""),
"last_seen_at": str(r["last_seen_at"] or ""),
}
for r in rows
]
def delete_whatsapp_contact(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
) -> bool:
with self._connect() as conn:
cur = conn.execute(
"""
DELETE FROM whatsapp_contact
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(tenant_id), str(account_id), str(external_user_id)),
)
return bool(cur.rowcount and cur.rowcount > 0)
def delete_whatsapp_contact_aliases(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
extra_tenant_ids: list[str] | None = None,
) -> int:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
normalize_whatsapp_phone,
phone_from_jid,
whatsapp_users_match,
)
tenant_ids: list[str] = []
for tid in [str(tenant_id), *(extra_tenant_ids or [])]:
val = str(tid or "").strip()
if val and val not in tenant_ids:
tenant_ids.append(val)
try:
phone_val = normalize_whatsapp_phone(external_user_id)
except Exception:
phone_val = phone_from_jid(external_user_id)
contacts: list[dict[str, Any]] = []
for tid in tenant_ids:
contacts.extend(self.list_whatsapp_contacts(tenant_id=tid, account_id=account_id, limit=500))
targets: set[str] = set()
for row in contacts:
cj = str(row.get("external_user_id") or "").strip()
if not cj:
continue
if whatsapp_users_match(cj, external_user_id):
targets.add(cj)
elif phone_val and contact_phone_key(row) == phone_val:
targets.add(cj)
deleted_count = 0
with self._connect() as conn:
for tid in tenant_ids:
for jid in targets:
if not jid:
continue
cur = conn.execute(
"""
DELETE FROM whatsapp_contact
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(tid), str(account_id), str(jid)),
)
if cur.rowcount:
deleted_count += int(cur.rowcount)
return deleted_count
def _ensure_whatsapp_access_pending_phone_column(self, conn: Any) -> None:
if self._use_pg:
conn.execute(
"ALTER TABLE whatsapp_access_pending ADD COLUMN IF NOT EXISTS phone TEXT NOT NULL DEFAULT ''"
)
return
cols = {row[1] for row in conn.execute("PRAGMA table_info(whatsapp_access_pending)").fetchall()}
if "phone" not in cols:
conn.execute("ALTER TABLE whatsapp_access_pending ADD COLUMN phone TEXT NOT NULL DEFAULT ''")
def create_whatsapp_access_pending(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
push_name: str = "",
phone: str = "",
request_text: str = "",
) -> str | None:
import uuid
from runtime.extensions.whatsapp.access_control import contact_phone_key, whatsapp_users_match
pending = self.list_whatsapp_access_pending(
tenant_id=tenant_id,
account_id=account_id,
status="pending",
limit=200,
)
phone_val = str(phone or "").strip()
for row in pending:
if whatsapp_users_match(str(row.get("external_user_id") or ""), external_user_id):
return str(row.get("id") or "")
if phone_val and contact_phone_key(row) == phone_val:
return str(row.get("id") or "")
pending_id = uuid.uuid4().hex
ts = utc_now_iso()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_access_pending
(id, tenant_id, account_id, external_user_id, push_name, phone, request_text, status, created_at, resolved_at, resolved_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?, NULL, '')
""",
(
pending_id,
str(tenant_id),
str(account_id),
str(external_user_id),
str(push_name or ""),
str(phone or ""),
str(request_text or ""),
ts,
),
)
return pending_id
def get_whatsapp_access_pending_by_id(self, *, pending_id: str) -> dict[str, Any] | None:
pid = str(pending_id or "").strip()
if not pid:
return None
with self._connect() as conn:
row = conn.execute(
"""
SELECT id, tenant_id, account_id, external_user_id, push_name, phone, request_text, status, created_at, resolved_at, resolved_by
FROM whatsapp_access_pending
WHERE id = ?
""",
(pid,),
).fetchone()
if not row:
return None
from runtime.extensions.whatsapp.access_control import phone_from_jid
return {
"id": str(row["id"] or ""),
"tenant_id": str(row["tenant_id"] or ""),
"account_id": str(row["account_id"] or ""),
"external_user_id": str(row["external_user_id"] or ""),
"push_name": str(row["push_name"] or ""),
"phone": str(row["phone"] or "") or phone_from_jid(str(row["external_user_id"] or "")),
"request_text": str(row["request_text"] or ""),
"status": str(row["status"] or ""),
"created_at": str(row["created_at"] or ""),
"resolved_at": str(row["resolved_at"] or "") or None,
"resolved_by": str(row["resolved_by"] or ""),
}
def upsert_whatsapp_pending_notify(
self,
*,
tenant_id: str,
account_id: str,
admin_chat_id: str,
notify_stanza_id: str,
pending_id: str,
notify_text: str = "",
) -> None:
ts = utc_now_iso()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO whatsapp_access_pending_notify
(tenant_id, account_id, admin_chat_id, notify_stanza_id, pending_id, notify_text, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, account_id, admin_chat_id, notify_stanza_id) DO UPDATE SET
pending_id = excluded.pending_id,
notify_text = CASE WHEN excluded.notify_text != '' THEN excluded.notify_text ELSE whatsapp_access_pending_notify.notify_text END,
created_at = excluded.created_at
""",
(
str(tenant_id),
str(account_id),
str(admin_chat_id),
str(notify_stanza_id),
str(pending_id),
str(notify_text or ""),
ts,
),
)
def find_whatsapp_pending_by_notify_stanza(
self,
*,
tenant_id: str,
account_id: str,
admin_chat_id: str,
notify_stanza_id: str,
) -> str | None:
stanza = str(notify_stanza_id or "").strip()
chat = str(admin_chat_id or "").strip()
if not stanza or not chat:
return None
with self._connect() as conn:
row = conn.execute(
"""
SELECT pending_id
FROM whatsapp_access_pending_notify
WHERE tenant_id = ? AND account_id = ? AND admin_chat_id = ? AND notify_stanza_id = ?
""",
(str(tenant_id), str(account_id), chat, stanza),
).fetchone()
return str(row["pending_id"] or "").strip() if row else None
def delete_whatsapp_pending_notify_for_pending(self, *, pending_id: str) -> int:
pid = str(pending_id or "").strip()
if not pid:
return 0
with self._connect() as conn:
cur = conn.execute(
"DELETE FROM whatsapp_access_pending_notify WHERE pending_id = ?",
(pid,),
)
return int(cur.rowcount or 0)
def list_whatsapp_access_pending(
self,
*,
tenant_id: str,
account_id: str,
status: str = "pending",
limit: int = 50,
) -> list[dict[str, Any]]:
lim = max(1, min(int(limit), 200))
from runtime.extensions.whatsapp.access_control import phone_from_jid
with self._connect() as conn:
rows = conn.execute(
"""
SELECT id, tenant_id, account_id, external_user_id, push_name, phone, request_text, status, created_at, resolved_at, resolved_by
FROM whatsapp_access_pending
WHERE tenant_id = ? AND account_id = ? AND status = ?
ORDER BY created_at ASC
LIMIT ?
""",
(str(tenant_id), str(account_id), str(status), lim),
).fetchall()
return [
{
"id": str(r["id"] or ""),
"tenant_id": str(r["tenant_id"] or ""),
"account_id": str(r["account_id"] or ""),
"external_user_id": str(r["external_user_id"] or ""),
"push_name": str(r["push_name"] or ""),
"phone": str(r["phone"] or "") or phone_from_jid(str(r["external_user_id"] or "")),
"request_text": str(r["request_text"] or ""),
"status": str(r["status"] or ""),
"created_at": str(r["created_at"] or ""),
"resolved_at": str(r["resolved_at"] or "") or None,
"resolved_by": str(r["resolved_by"] or ""),
}
for r in rows
]
def resolve_whatsapp_access_pending(
self,
*,
pending_id: str,
status: str,
resolved_by: str = "",
from_statuses: tuple[str, ...] = ("pending",),
) -> bool:
ts = utc_now_iso()
st = str(status or "").strip().lower()
if st not in {"approved", "denied", "dismissed"}:
return False
allowed = tuple(str(x or "").strip().lower() for x in from_statuses if str(x or "").strip())
if not allowed:
allowed = ("pending",)
placeholders = ", ".join("?" for _ in allowed)
with self._connect() as conn:
cur = conn.execute(
f"""
UPDATE whatsapp_access_pending
SET status = ?, resolved_at = ?, resolved_by = ?
WHERE id = ? AND status IN ({placeholders})
""",
(st, ts, str(resolved_by or ""), str(pending_id), *allowed),
)
return bool(cur.rowcount and cur.rowcount > 0)
def delete_whatsapp_access_pending(
self, *, pending_id: str, statuses: tuple[str, ...] = ("pending",)
) -> bool:
allowed = tuple(str(x or "").strip().lower() for x in statuses if str(x or "").strip())
if not allowed:
allowed = ("pending",)
placeholders = ", ".join("?" for _ in allowed)
with self._connect() as conn:
cur = conn.execute(
f"DELETE FROM whatsapp_access_pending WHERE id = ? AND status IN ({placeholders})",
(str(pending_id), *allowed),
)
return bool(cur.rowcount and cur.rowcount > 0)
def find_whatsapp_contact_for_sender(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
participant_alt: str = "",
remote_jid_alt: str = "",
) -> dict[str, Any] | None:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
resolve_sender_phone,
whatsapp_users_match,
)
sender_phone = resolve_sender_phone(external_user_id, participant_alt, remote_jid_alt)
contacts = self.list_whatsapp_contacts(tenant_id=tenant_id, account_id=account_id, limit=500)
classified = [row for row in contacts if str(row.get("list_type") or "").strip()]
if sender_phone:
for row in classified:
if contact_phone_key(row) == sender_phone:
return row
for row in classified:
cj = str(row.get("external_user_id") or "")
if whatsapp_users_match(cj, external_user_id) or (
participant_alt and whatsapp_users_match(cj, participant_alt)
):
return row
return None
def apply_whatsapp_contact_access(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
list_type: str,
push_name: str = "",
phone: str = "",
participant_alt: str = "",
notes: str = "",
) -> dict[str, Any]:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
normalize_whatsapp_phone,
)
from runtime.extensions.whatsapp.api import normalize_whatsapp_target
phone_val = ""
if str(phone or "").strip():
phone_val = normalize_whatsapp_phone(phone)
elif str(external_user_id or "").strip():
phone_val = normalize_whatsapp_phone(external_user_id)
else:
return {}
canonical = normalize_whatsapp_target(phone_val)
primary = self.upsert_whatsapp_contact(
tenant_id=tenant_id,
account_id=account_id,
external_user_id=canonical,
push_name=push_name,
phone=phone_val,
list_type=list_type,
notes=notes,
)
ts = utc_now_iso()
with self._connect() as conn:
for row in self.list_whatsapp_contacts(tenant_id=tenant_id, account_id=account_id, limit=500):
cj = str(row.get("external_user_id") or "")
if cj == canonical:
continue
if contact_phone_key(row) == phone_val:
conn.execute(
"""
UPDATE whatsapp_contact
SET list_type = ?, phone = ?, last_seen_at = ?
WHERE tenant_id = ? AND account_id = ? AND external_user_id = ?
""",
(str(list_type), phone_val, ts, str(tenant_id), str(account_id), cj),
)
return primary or {}
def resolve_whatsapp_pending_for_sender(
self,
*,
tenant_id: str,
account_id: str,
external_user_id: str,
participant_alt: str = "",
remote_jid_alt: str = "",
resolved_by: str = "",
status: str = "approved",
extra_tenant_ids: list[str] | None = None,
) -> int:
from runtime.extensions.whatsapp.access_control import (
contact_phone_key,
resolve_sender_phone,
whatsapp_users_match,
)
tenant_ids: list[str] = []
for tid in [str(tenant_id), *(extra_tenant_ids or [])]:
val = str(tid or "").strip()
if val and val not in tenant_ids:
tenant_ids.append(val)
sender_phone = resolve_sender_phone(external_user_id, participant_alt, remote_jid_alt)
resolved = 0
seen_ids: set[str] = set()
for tid in tenant_ids:
pending = self.list_whatsapp_access_pending(
tenant_id=tid,
account_id=account_id,
status="pending",
limit=200,
)
for item in pending:
pending_id = str(item.get("id") or "")
if not pending_id or pending_id in seen_ids:
continue
pending_jid = str(item.get("external_user_id") or "")
pending_phone = contact_phone_key(item)
if (sender_phone and pending_phone == sender_phone) or whatsapp_users_match(
pending_jid, external_user_id
) or (participant_alt and whatsapp_users_match(pending_jid, participant_alt)):
if self.resolve_whatsapp_access_pending(
pending_id=pending_id,
status=status,
resolved_by=resolved_by,
):
seen_ids.add(pending_id)
resolved += 1
return resolved
def enqueue_channel_outbound_message(
self,
*,
@ -5697,16 +6444,52 @@ class SqliteStore(ScheduledJobStoreMixin):
message_id: str,
ok: bool,
error: str = "",
stanza_id: str = "",
) -> bool:
ts = utc_now_iso()
status = "sent" if ok else "failed"
mid = str(message_id or "").strip()
if not mid:
return False
with self._connect() as conn:
row = conn.execute(
"""
SELECT tenant_id, account_id, chat_id, text, source
FROM channel_outbound_message
WHERE id = ? AND status = 'pending'
""",
(mid,),
).fetchone()
if not row:
return False
cur = conn.execute(
"""
UPDATE channel_outbound_message
SET status = ?, sent_at = ?, error = ?
WHERE id = ? AND status = 'pending'
""",
(status, ts, str(error or ""), str(message_id)),
(status, ts, str(error or ""), mid),
)
return bool(cur.rowcount and cur.rowcount > 0)
changed = bool(cur.rowcount and cur.rowcount > 0)
if changed and ok and str(stanza_id or "").strip():
source_raw = str(row["source"] or "").strip()
meta: dict[str, Any] = {}
if source_raw.startswith("{"):
try:
parsed = json.loads(source_raw)
if isinstance(parsed, dict):
meta = parsed
except Exception:
meta = {}
if str(meta.get("kind") or "") == "whatsapp_access_pending":
pending_id = str(meta.get("pending_id") or "").strip()
if pending_id:
self.upsert_whatsapp_pending_notify(
tenant_id=str(row["tenant_id"] or ""),
account_id=str(row["account_id"] or ""),
admin_chat_id=str(row["chat_id"] or ""),
notify_stanza_id=str(stanza_id or "").strip(),
pending_id=pending_id,
notify_text=str(row["text"] or ""),
)
return changed