feat(agents): Add identity and soul template fields to board creation
This commit is contained in:
@@ -208,7 +208,9 @@ async def create_agent(
|
||||
)
|
||||
session.commit()
|
||||
try:
|
||||
await send_provisioning_message(agent, board, raw_token, provision_token)
|
||||
await send_provisioning_message(
|
||||
agent, board, raw_token, provision_token, auth.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.provision.requested",
|
||||
@@ -288,7 +290,9 @@ async def update_agent(
|
||||
session.commit()
|
||||
session.refresh(agent)
|
||||
try:
|
||||
await send_update_message(agent, board, raw_token, provision_token)
|
||||
await send_update_message(
|
||||
agent, board, raw_token, provision_token, auth.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.update.requested",
|
||||
@@ -375,7 +379,9 @@ async def heartbeat_or_create_agent(
|
||||
)
|
||||
session.commit()
|
||||
try:
|
||||
await send_provisioning_message(agent, board, raw_token, provision_token)
|
||||
await send_provisioning_message(
|
||||
agent, board, raw_token, provision_token, actor.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.provision.requested",
|
||||
@@ -405,7 +411,9 @@ async def heartbeat_or_create_agent(
|
||||
try:
|
||||
board = _require_board(session, str(agent.board_id) if agent.board_id else None)
|
||||
config = _require_gateway_config(board)
|
||||
await send_provisioning_message(agent, board, raw_token, provision_token)
|
||||
await send_provisioning_message(
|
||||
agent, board, raw_token, provision_token, actor.user
|
||||
)
|
||||
record_activity(
|
||||
session,
|
||||
event_type="agent.provision.requested",
|
||||
|
||||
@@ -101,6 +101,10 @@ def create_board(
|
||||
data = payload.model_dump()
|
||||
if data.get("gateway_token") == "":
|
||||
data["gateway_token"] = None
|
||||
if data.get("identity_template") == "":
|
||||
data["identity_template"] = None
|
||||
if data.get("soul_template") == "":
|
||||
data["soul_template"] = None
|
||||
if data.get("gateway_url"):
|
||||
if not data.get("gateway_main_session_key"):
|
||||
raise HTTPException(
|
||||
@@ -137,6 +141,10 @@ def update_board(
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if updates.get("gateway_token") == "":
|
||||
updates["gateway_token"] = None
|
||||
if updates.get("identity_template") == "":
|
||||
updates["identity_template"] = None
|
||||
if updates.get("soul_template") == "":
|
||||
updates["soul_template"] = None
|
||||
for key, value in updates.items():
|
||||
setattr(board, key, value)
|
||||
if board.gateway_url:
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import asc, desc
|
||||
from sqlmodel import Session, col, select
|
||||
|
||||
@@ -70,11 +70,26 @@ def has_valid_recent_comment(
|
||||
|
||||
@router.get("", response_model=list[TaskRead])
|
||||
def list_tasks(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
assigned_agent_id: UUID | None = None,
|
||||
unassigned: bool | None = None,
|
||||
limit: int | None = Query(default=None, ge=1, le=200),
|
||||
board: Board = Depends(get_board_or_404),
|
||||
session: Session = Depends(get_session),
|
||||
actor: ActorContext = Depends(require_admin_or_agent),
|
||||
) -> list[Task]:
|
||||
return list(session.exec(select(Task).where(Task.board_id == board.id)))
|
||||
statement = select(Task).where(Task.board_id == board.id)
|
||||
if status_filter:
|
||||
statuses = [s.strip() for s in status_filter.split(",") if s.strip()]
|
||||
if statuses:
|
||||
statement = statement.where(col(Task.status).in_(statuses))
|
||||
if assigned_agent_id is not None:
|
||||
statement = statement.where(col(Task.assigned_agent_id) == assigned_agent_id)
|
||||
if unassigned:
|
||||
statement = statement.where(col(Task.assigned_agent_id).is_(None))
|
||||
if limit is not None:
|
||||
statement = statement.limit(limit)
|
||||
return list(session.exec(statement))
|
||||
|
||||
|
||||
@router.post("", response_model=TaskRead)
|
||||
|
||||
36
backend/app/api/users.py
Normal file
36
backend/app/api/users.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.auth import AuthContext, get_auth_context
|
||||
from app.db.session import get_session
|
||||
from app.models.users import User
|
||||
from app.schemas.users import UserRead, UserUpdate
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserRead)
|
||||
async def get_me(auth: AuthContext = Depends(get_auth_context)) -> UserRead:
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
return UserRead.model_validate(auth.user)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserRead)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
session: Session = Depends(get_session),
|
||||
auth: AuthContext = Depends(get_auth_context),
|
||||
) -> UserRead:
|
||||
if auth.actor_type != "user" or auth.user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
user: User = auth.user
|
||||
for key, value in updates.items():
|
||||
setattr(user, key, value)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
session.refresh(user)
|
||||
return UserRead.model_validate(user)
|
||||
@@ -25,5 +25,10 @@ class Settings(BaseSettings):
|
||||
# Database lifecycle
|
||||
db_auto_migrate: bool = False
|
||||
|
||||
# Logging
|
||||
log_level: str = "INFO"
|
||||
log_format: str = "text"
|
||||
log_use_utc: bool = False
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,14 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.version import APP_NAME, APP_VERSION
|
||||
|
||||
TRACE_LEVEL = 5
|
||||
logging.addLevelName(TRACE_LEVEL, "TRACE")
|
||||
|
||||
|
||||
def _trace(self: logging.Logger, message: str, *args: Any, **kwargs: Any) -> None:
|
||||
if self.isEnabledFor(TRACE_LEVEL):
|
||||
self._log(TRACE_LEVEL, message, args, **kwargs)
|
||||
|
||||
|
||||
logging.Logger.trace = _trace # type: ignore[attr-defined]
|
||||
|
||||
_STANDARD_LOG_RECORD_ATTRS = {
|
||||
"args",
|
||||
"asctime",
|
||||
"created",
|
||||
"exc_info",
|
||||
"exc_text",
|
||||
"filename",
|
||||
"funcName",
|
||||
"levelname",
|
||||
"levelno",
|
||||
"lineno",
|
||||
"module",
|
||||
"msecs",
|
||||
"message",
|
||||
"msg",
|
||||
"name",
|
||||
"pathname",
|
||||
"process",
|
||||
"processName",
|
||||
"relativeCreated",
|
||||
"stack_info",
|
||||
"thread",
|
||||
"threadName",
|
||||
"taskName",
|
||||
"app",
|
||||
"version",
|
||||
}
|
||||
|
||||
|
||||
class AppLogFilter(logging.Filter):
|
||||
def __init__(self, app_name: str, version: str) -> None:
|
||||
super().__init__()
|
||||
self._app_name = app_name
|
||||
self._version = version
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
record.app = self._app_name
|
||||
record.version = self._version
|
||||
return True
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
payload: dict[str, Any] = {
|
||||
"timestamp": datetime.fromtimestamp(
|
||||
record.created, tz=timezone.utc
|
||||
).isoformat(),
|
||||
"level": record.levelname,
|
||||
"logger": record.name,
|
||||
"message": record.getMessage(),
|
||||
"app": getattr(record, "app", APP_NAME),
|
||||
"version": getattr(record, "version", APP_VERSION),
|
||||
"module": record.module,
|
||||
"function": record.funcName,
|
||||
"line": record.lineno,
|
||||
}
|
||||
if record.exc_info:
|
||||
payload["exception"] = self.formatException(record.exc_info)
|
||||
if record.stack_info:
|
||||
payload["stack"] = self.formatStack(record.stack_info)
|
||||
for key, value in record.__dict__.items():
|
||||
if key in _STANDARD_LOG_RECORD_ATTRS or key in payload:
|
||||
continue
|
||||
payload[key] = value
|
||||
return json.dumps(payload, separators=(",", ":"), default=str)
|
||||
|
||||
|
||||
class KeyValueFormatter(logging.Formatter):
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
base = super().format(record)
|
||||
extras = {
|
||||
key: value
|
||||
for key, value in record.__dict__.items()
|
||||
if key not in _STANDARD_LOG_RECORD_ATTRS
|
||||
}
|
||||
if not extras:
|
||||
return base
|
||||
extra_bits = " ".join(f"{key}={value}" for key, value in extras.items())
|
||||
return f"{base} {extra_bits}"
|
||||
|
||||
|
||||
class AppLogger:
|
||||
_configured = False
|
||||
|
||||
@classmethod
|
||||
def _resolve_level(cls) -> tuple[str, int]:
|
||||
level_name = (settings.log_level or os.getenv("LOG_LEVEL", "INFO")).upper()
|
||||
if level_name == "TRACE":
|
||||
return level_name, TRACE_LEVEL
|
||||
if level_name.isdigit():
|
||||
return level_name, int(level_name)
|
||||
return level_name, logging._nameToLevel.get(level_name, logging.INFO)
|
||||
|
||||
@classmethod
|
||||
def configure(cls, *, force: bool = False) -> None:
|
||||
if cls._configured and not force:
|
||||
return
|
||||
|
||||
level_name, level = cls._resolve_level()
|
||||
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.addFilter(AppLogFilter(APP_NAME, APP_VERSION))
|
||||
format_name = (settings.log_format or "text").lower()
|
||||
if format_name == "json":
|
||||
formatter: logging.Formatter = JsonFormatter()
|
||||
else:
|
||||
formatter = KeyValueFormatter(
|
||||
"%(asctime)s %(levelname)s %(name)s %(message)s app=%(app)s version=%(version)s"
|
||||
)
|
||||
if settings.log_use_utc:
|
||||
formatter.converter = time.gmtime
|
||||
handler.setFormatter(formatter)
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
root.handlers.clear()
|
||||
root.addHandler(handler)
|
||||
|
||||
# Uvicorn & HTTP clients
|
||||
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
logging.getLogger(logger_name).setLevel(level)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||
|
||||
# SQL logs only at TRACE
|
||||
sql_loggers = ("sqlalchemy", "sqlalchemy.engine", "sqlalchemy.pool")
|
||||
if level_name == "TRACE":
|
||||
for name in sql_loggers:
|
||||
logger = logging.getLogger(name)
|
||||
logger.disabled = False
|
||||
logger.setLevel(logging.INFO)
|
||||
else:
|
||||
for name in sql_loggers:
|
||||
logger = logging.getLogger(name)
|
||||
logger.disabled = True
|
||||
|
||||
cls._configured = True
|
||||
|
||||
@classmethod
|
||||
def get_logger(cls, name: str | None = None) -> logging.Logger:
|
||||
if not cls._configured:
|
||||
cls.configure()
|
||||
return logging.getLogger(name)
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
level = logging._nameToLevel.get(level_name, logging.INFO)
|
||||
logging.basicConfig(
|
||||
level=level,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
force=True,
|
||||
)
|
||||
AppLogger.configure()
|
||||
|
||||
2
backend/app/core/version.py
Normal file
2
backend/app/core/version.py
Normal file
@@ -0,0 +1,2 @@
|
||||
APP_NAME = "mission-control"
|
||||
APP_VERSION = "0.1.0"
|
||||
@@ -9,6 +9,7 @@ from app.api.auth import router as auth_router
|
||||
from app.api.boards import router as boards_router
|
||||
from app.api.gateway import router as gateway_router
|
||||
from app.api.tasks import router as tasks_router
|
||||
from app.api.users import router as users_router
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging
|
||||
from app.db.session import init_db
|
||||
@@ -38,6 +39,16 @@ def health() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/readyz")
|
||||
def readyz() -> dict[str, bool]:
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
api_v1 = APIRouter(prefix="/api/v1")
|
||||
api_v1.include_router(auth_router)
|
||||
api_v1.include_router(agents_router)
|
||||
@@ -45,4 +56,5 @@ api_v1.include_router(activity_router)
|
||||
api_v1.include_router(gateway_router)
|
||||
api_v1.include_router(boards_router)
|
||||
api_v1.include_router(tasks_router)
|
||||
api_v1.include_router(users_router)
|
||||
app.include_router(api_v1)
|
||||
|
||||
@@ -18,5 +18,7 @@ class Board(TenantScoped, table=True):
|
||||
gateway_token: str | None = Field(default=None)
|
||||
gateway_main_session_key: str | None = Field(default=None)
|
||||
gateway_workspace_root: str | None = Field(default=None)
|
||||
identity_template: str | None = Field(default=None)
|
||||
soul_template: str | None = Field(default=None)
|
||||
created_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
@@ -12,4 +12,9 @@ class User(SQLModel, table=True):
|
||||
clerk_user_id: str = Field(index=True, unique=True)
|
||||
email: str | None = Field(default=None, index=True)
|
||||
name: str | None = None
|
||||
preferred_name: str | None = None
|
||||
pronouns: str | None = None
|
||||
timezone: str | None = None
|
||||
notes: str | None = None
|
||||
context: str | None = None
|
||||
is_super_admin: bool = Field(default=False)
|
||||
|
||||
@@ -2,7 +2,7 @@ from app.schemas.activity_events import ActivityEventRead
|
||||
from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate
|
||||
from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate
|
||||
from app.schemas.tasks import TaskCreate, TaskRead, TaskUpdate
|
||||
from app.schemas.users import UserCreate, UserRead
|
||||
from app.schemas.users import UserCreate, UserRead, UserUpdate
|
||||
|
||||
__all__ = [
|
||||
"ActivityEventRead",
|
||||
@@ -17,4 +17,5 @@ __all__ = [
|
||||
"TaskUpdate",
|
||||
"UserCreate",
|
||||
"UserRead",
|
||||
"UserUpdate",
|
||||
]
|
||||
|
||||
@@ -12,6 +12,8 @@ class BoardBase(SQLModel):
|
||||
gateway_url: str | None = None
|
||||
gateway_main_session_key: str | None = None
|
||||
gateway_workspace_root: str | None = None
|
||||
identity_template: str | None = None
|
||||
soul_template: str | None = None
|
||||
|
||||
|
||||
class BoardCreate(BoardBase):
|
||||
@@ -25,6 +27,8 @@ class BoardUpdate(SQLModel):
|
||||
gateway_token: str | None = None
|
||||
gateway_main_session_key: str | None = None
|
||||
gateway_workspace_root: str | None = None
|
||||
identity_template: str | None = None
|
||||
soul_template: str | None = None
|
||||
|
||||
|
||||
class BoardRead(BoardBase):
|
||||
|
||||
@@ -9,12 +9,26 @@ class UserBase(SQLModel):
|
||||
clerk_user_id: str
|
||||
email: str | None = None
|
||||
name: str | None = None
|
||||
preferred_name: str | None = None
|
||||
pronouns: str | None = None
|
||||
timezone: str | None = None
|
||||
notes: str | None = None
|
||||
context: str | None = None
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
pass
|
||||
|
||||
|
||||
class UserUpdate(SQLModel):
|
||||
name: str | None = None
|
||||
preferred_name: str | None = None
|
||||
pronouns: str | None = None
|
||||
timezone: str | None = None
|
||||
notes: str | None = None
|
||||
context: str | None = None
|
||||
|
||||
|
||||
class UserRead(UserBase):
|
||||
id: UUID
|
||||
is_super_admin: bool
|
||||
|
||||
@@ -12,6 +12,7 @@ from app.core.config import settings
|
||||
from app.integrations.openclaw_gateway import GatewayConfig, ensure_session, send_message
|
||||
from app.models.agents import Agent
|
||||
from app.models.boards import Board
|
||||
from app.models.users import User
|
||||
|
||||
TEMPLATE_FILES = [
|
||||
"AGENTS.md",
|
||||
@@ -64,11 +65,18 @@ def _template_env() -> Environment:
|
||||
)
|
||||
|
||||
|
||||
def _read_templates(context: dict[str, str]) -> dict[str, str]:
|
||||
def _read_templates(
|
||||
context: dict[str, str], overrides: dict[str, str] | None = None
|
||||
) -> dict[str, str]:
|
||||
env = _template_env()
|
||||
templates: dict[str, str] = {}
|
||||
override_map = overrides or {}
|
||||
for name in TEMPLATE_FILES:
|
||||
path = _templates_root() / name
|
||||
override = override_map.get(name)
|
||||
if override:
|
||||
templates[name] = env.from_string(override).render(**context).strip()
|
||||
continue
|
||||
if not path.exists():
|
||||
templates[name] = ""
|
||||
continue
|
||||
@@ -90,7 +98,9 @@ def _workspace_path(agent_name: str, workspace_root: str) -> str:
|
||||
return f"{root}/workspace-{_slugify(agent_name)}"
|
||||
|
||||
|
||||
def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str]:
|
||||
def _build_context(
|
||||
agent: Agent, board: Board, auth_token: str, user: User | None
|
||||
) -> dict[str, str]:
|
||||
if not board.gateway_workspace_root:
|
||||
raise ValueError("gateway_workspace_root is required")
|
||||
if not board.gateway_main_session_key:
|
||||
@@ -111,25 +121,32 @@ def _build_context(agent: Agent, board: Board, auth_token: str) -> dict[str, str
|
||||
"auth_token": auth_token,
|
||||
"main_session_key": main_session_key,
|
||||
"workspace_root": workspace_root,
|
||||
"user_name": "Unset",
|
||||
"user_preferred_name": "Unset",
|
||||
"user_timezone": "Unset",
|
||||
"user_notes": "Fill in user context.",
|
||||
"user_name": user.name if user else "",
|
||||
"user_preferred_name": user.preferred_name if user else "",
|
||||
"user_pronouns": user.pronouns if user else "",
|
||||
"user_timezone": user.timezone if user else "",
|
||||
"user_notes": user.notes if user else "",
|
||||
"user_context": user.context if user else "",
|
||||
}
|
||||
|
||||
|
||||
def _build_file_blocks(context: dict[str, str]) -> str:
|
||||
templates = _read_templates(context)
|
||||
def _build_file_blocks(context: dict[str, str], board: Board) -> str:
|
||||
overrides: dict[str, str] = {}
|
||||
if board.identity_template:
|
||||
overrides["IDENTITY.md"] = board.identity_template
|
||||
if board.soul_template:
|
||||
overrides["SOUL.md"] = board.soul_template
|
||||
templates = _read_templates(context, overrides=overrides)
|
||||
return "".join(
|
||||
_render_file_block(name, templates.get(name, "")) for name in TEMPLATE_FILES
|
||||
)
|
||||
|
||||
|
||||
def build_provisioning_message(
|
||||
agent: Agent, board: Board, auth_token: str, confirm_token: str
|
||||
agent: Agent, board: Board, auth_token: str, confirm_token: str, user: User | None
|
||||
) -> str:
|
||||
context = _build_context(agent, board, auth_token)
|
||||
file_blocks = _build_file_blocks(context)
|
||||
context = _build_context(agent, board, auth_token, user)
|
||||
file_blocks = _build_file_blocks(context, board)
|
||||
heartbeat_snippet = json.dumps(
|
||||
{
|
||||
"id": _agent_key(agent),
|
||||
@@ -173,10 +190,10 @@ def build_provisioning_message(
|
||||
|
||||
|
||||
def build_update_message(
|
||||
agent: Agent, board: Board, auth_token: str, confirm_token: str
|
||||
agent: Agent, board: Board, auth_token: str, confirm_token: str, user: User | None
|
||||
) -> str:
|
||||
context = _build_context(agent, board, auth_token)
|
||||
file_blocks = _build_file_blocks(context)
|
||||
context = _build_context(agent, board, auth_token, user)
|
||||
file_blocks = _build_file_blocks(context, board)
|
||||
heartbeat_snippet = json.dumps(
|
||||
{
|
||||
"id": _agent_key(agent),
|
||||
@@ -223,6 +240,7 @@ async def send_provisioning_message(
|
||||
board: Board,
|
||||
auth_token: str,
|
||||
confirm_token: str,
|
||||
user: User | None,
|
||||
) -> None:
|
||||
if not board.gateway_url:
|
||||
return
|
||||
@@ -231,7 +249,7 @@ async def send_provisioning_message(
|
||||
main_session = board.gateway_main_session_key
|
||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
message = build_provisioning_message(agent, board, auth_token, confirm_token)
|
||||
message = build_provisioning_message(agent, board, auth_token, confirm_token, user)
|
||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
||||
|
||||
|
||||
@@ -240,6 +258,7 @@ async def send_update_message(
|
||||
board: Board,
|
||||
auth_token: str,
|
||||
confirm_token: str,
|
||||
user: User | None,
|
||||
) -> None:
|
||||
if not board.gateway_url:
|
||||
return
|
||||
@@ -248,5 +267,5 @@ async def send_update_message(
|
||||
main_session = board.gateway_main_session_key
|
||||
config = GatewayConfig(url=board.gateway_url, token=board.gateway_token)
|
||||
await ensure_session(main_session, config=config, label="Main Agent")
|
||||
message = build_update_message(agent, board, auth_token, confirm_token)
|
||||
message = build_update_message(agent, board, auth_token, confirm_token, user)
|
||||
await send_message(message, session_key=main_session, config=config, deliver=False)
|
||||
|
||||
Reference in New Issue
Block a user