mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-11 12:10:45 +08:00
feat(scheduler): add scheduled jobs with channel-aware delivery
Introduce persisted cron/interval jobs, gateway scheduler loop, worker turns, and admin CRUD/edit UI. Route proactive reminders via the originating chat channel (WhatsApp vs WeChat), harden Weixin outbound with durable queue and PG-compatible polling, and fix chat UI to show each scheduled reminder separately. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5336f8835f
commit
47813f6a94
45 changed files with 4956 additions and 43 deletions
|
|
@ -20,6 +20,7 @@ from sqlalchemy.exc import IntegrityError
|
|||
|
||||
from svc.persistence.pg_adapter import PgConnShim, connect_postgres
|
||||
from svc.persistence.pg_compat import scrub_nul_bytes_from_jsonable, scrub_nul_bytes_from_text
|
||||
from svc.persistence.scheduled_job_store import ScheduledJobStoreMixin
|
||||
|
||||
if sys.platform == "win32":
|
||||
import ctypes
|
||||
|
|
@ -297,7 +298,7 @@ def _decode_secret(secret_text: str) -> str:
|
|||
raise _CryptoError("未知的密钥编码格式")
|
||||
|
||||
|
||||
class SqliteStore:
|
||||
class SqliteStore(ScheduledJobStoreMixin):
|
||||
@staticmethod
|
||||
def _cap_json_for_log(obj: Any, *, max_chars: int, keep_keys: tuple[str, ...] = ("ok", "error_code", "error")) -> Any:
|
||||
cap = max(2000, int(max_chars or 0))
|
||||
|
|
@ -677,6 +678,7 @@ class SqliteStore:
|
|||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_channel_outbound_pending ON channel_outbound_message(channel, account_id, status, created_at)"
|
||||
)
|
||||
self.ensure_scheduled_job_tables(conn)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS todo_item (
|
||||
|
|
@ -1266,6 +1268,7 @@ class SqliteStore:
|
|||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_channel_outbound_pending ON channel_outbound_message(channel, account_id, status, created_at)"
|
||||
)
|
||||
self.ensure_scheduled_job_tables(conn)
|
||||
self._seed_builtin_llm_profiles(conn)
|
||||
self._seed_default_permissions(conn)
|
||||
conn.execute(
|
||||
|
|
@ -4713,6 +4716,186 @@ class SqliteStore:
|
|||
for r in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _channel_context_token_key(
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
external_chat_id: str,
|
||||
) -> str:
|
||||
return (
|
||||
f"channel_ctx_token:{str(tenant_id or '').strip()}:"
|
||||
f"{str(channel or '').strip().lower()}:"
|
||||
f"{str(account_id or '').strip()}:"
|
||||
f"{str(external_chat_id or '').strip()}"
|
||||
)
|
||||
|
||||
def set_channel_context_token(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
external_chat_id: str,
|
||||
context_token: str,
|
||||
) -> None:
|
||||
tok = str(context_token or "").strip()
|
||||
chat_id = str(external_chat_id or "").strip()
|
||||
if not tok or not chat_id:
|
||||
return
|
||||
self.set_setting(
|
||||
self._channel_context_token_key(
|
||||
tenant_id=tenant_id,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
),
|
||||
tok,
|
||||
)
|
||||
|
||||
def get_channel_context_token(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
external_chat_id: str,
|
||||
) -> str:
|
||||
chat_id = str(external_chat_id or "").strip()
|
||||
if not chat_id:
|
||||
return ""
|
||||
return str(
|
||||
self.get_setting(
|
||||
self._channel_context_token_key(
|
||||
tenant_id=tenant_id,
|
||||
channel=channel,
|
||||
account_id=account_id,
|
||||
external_chat_id=chat_id,
|
||||
)
|
||||
)
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
def get_channel_context_token_fuzzy(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
account_id: str,
|
||||
external_chat_id: str,
|
||||
) -> str:
|
||||
chat_id = str(external_chat_id or "").strip()
|
||||
if not chat_id:
|
||||
return ""
|
||||
channels = []
|
||||
for ch in (channel, "wechat", "weixin"):
|
||||
c = str(ch or "").strip().lower()
|
||||
if c and c not in channels:
|
||||
channels.append(c)
|
||||
account_ids: list[str] = []
|
||||
for aid in (account_id, ""):
|
||||
a = str(aid or "").strip()
|
||||
if a not in account_ids:
|
||||
account_ids.append(a)
|
||||
for ch in channels:
|
||||
for aid in account_ids:
|
||||
tok = self.get_channel_context_token(
|
||||
tenant_id=tenant_id,
|
||||
channel=ch,
|
||||
account_id=aid,
|
||||
external_chat_id=chat_id,
|
||||
)
|
||||
if tok:
|
||||
return tok
|
||||
return ""
|
||||
|
||||
def lookup_channel_session_by_chat_v2(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
channel: str,
|
||||
external_chat_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
chat_id = str(external_chat_id or "").strip()
|
||||
tid = str(tenant_id or "").strip()
|
||||
if not chat_id or not tid:
|
||||
return None
|
||||
channels = []
|
||||
for ch in (channel, "wechat", "weixin"):
|
||||
c = str(ch or "").strip().lower()
|
||||
if c and c not in channels:
|
||||
channels.append(c)
|
||||
with self._connect() as conn:
|
||||
for ch in channels:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT tenant_id, channel, account_id, external_chat_id, external_user_id, session_id
|
||||
FROM channel_session_v2
|
||||
WHERE tenant_id = ? AND channel = ? AND external_chat_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(tid, ch, chat_id),
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"tenant_id": str(row["tenant_id"] or ""),
|
||||
"channel": str(row["channel"] or ""),
|
||||
"account_id": str(row["account_id"] or ""),
|
||||
"external_chat_id": str(row["external_chat_id"] or ""),
|
||||
"external_user_id": str(row["external_user_id"] or ""),
|
||||
"session_id": str(row["session_id"] or ""),
|
||||
}
|
||||
return None
|
||||
|
||||
def lookup_channel_session_by_session_id(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
session_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
sid = str(session_id or "").strip()
|
||||
tid = str(tenant_id or "").strip()
|
||||
if not sid or not tid:
|
||||
return None
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT tenant_id, channel, account_id, external_chat_id, external_user_id
|
||||
FROM channel_session_v2
|
||||
WHERE tenant_id = ? AND session_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(tid, sid),
|
||||
).fetchone()
|
||||
if row:
|
||||
return {
|
||||
"tenant_id": str(row["tenant_id"] or ""),
|
||||
"channel": str(row["channel"] or ""),
|
||||
"account_id": str(row["account_id"] or ""),
|
||||
"external_chat_id": str(row["external_chat_id"] or ""),
|
||||
"external_user_id": str(row["external_user_id"] or ""),
|
||||
}
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT tenant_id, channel, external_chat_id, external_user_id
|
||||
FROM channel_session
|
||||
WHERE tenant_id = ? AND session_id = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(tid, sid),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return {
|
||||
"tenant_id": str(row["tenant_id"] or ""),
|
||||
"channel": str(row["channel"] or ""),
|
||||
"account_id": "",
|
||||
"external_chat_id": str(row["external_chat_id"] or ""),
|
||||
"external_user_id": str(row["external_user_id"] or ""),
|
||||
}
|
||||
|
||||
def get_or_create_channel_session(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -5446,18 +5629,68 @@ class SqliteStore:
|
|||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"id": str(r[0] or ""),
|
||||
"tenant_id": str(r[1] or ""),
|
||||
"channel": str(r[2] or ""),
|
||||
"account_id": str(r[3] or ""),
|
||||
"chat_id": str(r[4] or ""),
|
||||
"text": str(r[5] or ""),
|
||||
"source": str(r[6] or ""),
|
||||
"created_at": str(r[7] or ""),
|
||||
"id": str(r["id"] or ""),
|
||||
"tenant_id": str(r["tenant_id"] or ""),
|
||||
"channel": str(r["channel"] or ""),
|
||||
"account_id": str(r["account_id"] or ""),
|
||||
"chat_id": str(r["chat_id"] or ""),
|
||||
"text": str(r["text"] or ""),
|
||||
"source": str(r["source"] or ""),
|
||||
"created_at": str(r["created_at"] or ""),
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def list_pending_weixin_outbound_messages(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
lim = max(1, min(int(limit), 100))
|
||||
aid = str(account_id or "").strip()
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, tenant_id, channel, account_id, chat_id, text, source, created_at
|
||||
FROM channel_outbound_message
|
||||
WHERE channel IN ('wechat', 'weixin') AND status = 'pending'
|
||||
AND (
|
||||
account_id = ?
|
||||
OR account_id = '' OR account_id = 'weixin-default'
|
||||
OR ? = '' OR ? = 'weixin-default'
|
||||
)
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
""",
|
||||
(aid, aid, aid, lim),
|
||||
).fetchall()
|
||||
out: list[dict[str, Any]] = []
|
||||
for r in rows:
|
||||
source = str(r["source"] or "")
|
||||
meta = {}
|
||||
if source.startswith("{"):
|
||||
try:
|
||||
parsed = json.loads(source)
|
||||
if isinstance(parsed, dict):
|
||||
meta = parsed
|
||||
except Exception:
|
||||
meta = {}
|
||||
out.append(
|
||||
{
|
||||
"id": str(r["id"] or ""),
|
||||
"tenant_id": str(r["tenant_id"] or ""),
|
||||
"channel": str(r["channel"] or ""),
|
||||
"account_id": str(r["account_id"] or ""),
|
||||
"chat_id": str(r["chat_id"] or ""),
|
||||
"text": str(r["text"] or ""),
|
||||
"source": source,
|
||||
"context_token": str(meta.get("context_token") or ""),
|
||||
"created_at": str(r["created_at"] or ""),
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def ack_channel_outbound_message(
|
||||
self,
|
||||
*,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue