Increase security and reformat

This commit is contained in:
2026-03-07 20:52:13 +00:00
parent 10588a18b9
commit bca4d6898f
144 changed files with 6735 additions and 3960 deletions

View File

@@ -1,4 +1,5 @@
"""Test-only settings overrides — used via DJANGO_SETTINGS_MODULE=app.test_settings."""
from app.settings import * # noqa: F401, F403
CACHES = {

View File

@@ -13,12 +13,13 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin
from django.contrib.auth.views import LogoutView
from django.views.generic import RedirectView
from django.urls import include, path
from django.views.generic import RedirectView
from two_factor.urls import urlpatterns as tf_urls
from two_factor.views.profile import ProfileView
@@ -41,8 +42,8 @@ from core.views import (
queues,
sessions,
signal,
tasks,
system,
tasks,
whatsapp,
workspace,
)
@@ -188,6 +189,11 @@ urlpatterns = [
automation.TranslationSettings.as_view(),
name="translation_settings",
),
path(
"settings/business-plans/",
automation.BusinessPlanInbox.as_view(),
name="business_plan_inbox",
),
path(
"settings/business-plan/<str:doc_id>/",
automation.BusinessPlanEditor.as_view(),

View File

@@ -38,14 +38,31 @@ def _is_question(text: str) -> bool:
if not body:
return False
low = body.lower()
return body.endswith("?") or low.startswith(("what", "why", "how", "when", "where", "who", "can ", "do ", "did ", "is ", "are "))
return body.endswith("?") or low.startswith(
(
"what",
"why",
"how",
"when",
"where",
"who",
"can ",
"do ",
"did ",
"is ",
"are ",
)
)
def _is_group_channel(message: Message) -> bool:
channel = str(getattr(message, "source_chat_id", "") or "").strip().lower()
if channel.endswith("@g.us"):
return True
return str(getattr(message, "source_service", "") or "").strip().lower() == "xmpp" and "conference." in channel
return (
str(getattr(message, "source_service", "") or "").strip().lower() == "xmpp"
and "conference." in channel
)
async def learn_from_message(message: Message) -> None:

View File

@@ -12,8 +12,7 @@ class ClientBase(ABC):
self.log.info(f"{self.service.capitalize()} client initialising...")
@abstractmethod
def start(self):
...
def start(self): ...
# @abstractmethod
# async def send_message(self, recipient, message):

View File

@@ -12,7 +12,15 @@ from django.urls import reverse
from signalbot import Command, Context, SignalBot
from core.clients import ClientBase, signalapi, transport
from core.messaging import ai, history, media_bridge, natural, replies, reply_sync, utils
from core.messaging import (
ai,
history,
media_bridge,
natural,
replies,
reply_sync,
utils,
)
from core.models import (
Chat,
Manipulation,
@@ -402,7 +410,9 @@ class NewSignalBot(SignalBot):
seen_user_ids.add(pi.user_id)
users.append(pi.user)
if not users:
self.log.debug("[Signal] _upsert_groups: no PersonIdentifiers found — skipping")
self.log.debug(
"[Signal] _upsert_groups: no PersonIdentifiers found — skipping"
)
return
for user in users:
@@ -423,7 +433,9 @@ class NewSignalBot(SignalBot):
},
)
self.log.info("[Signal] upserted %d groups for %d users", len(groups), len(users))
self.log.info(
"[Signal] upserted %d groups for %d users", len(groups), len(users)
)
async def _detect_groups(self):
await super()._detect_groups()
@@ -505,7 +517,9 @@ class HandleMessage(Command):
source_uuid_norm and dest_norm and source_uuid_norm == dest_norm
)
is_from_bot = bool(bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid)
is_from_bot = bool(
bot_uuid and source_uuid_norm and source_uuid_norm == bot_uuid
)
if (not is_from_bot) and bot_phone_digits and source_phone_digits:
is_from_bot = source_phone_digits == bot_phone_digits
# Inbound deliveries usually do not have destination fields populated.
@@ -596,9 +610,9 @@ class HandleMessage(Command):
candidate_digits = {value for value in candidate_digits if value}
if candidate_digits:
signal_rows = await sync_to_async(list)(
PersonIdentifier.objects.filter(service=self.service).select_related(
"user"
)
PersonIdentifier.objects.filter(
service=self.service
).select_related("user")
)
matched = []
for row in signal_rows:
@@ -718,13 +732,13 @@ class HandleMessage(Command):
target_ts=int(reaction_payload.get("target_ts") or 0),
emoji=str(reaction_payload.get("emoji") or ""),
source_service="signal",
actor=(
effective_source_uuid or effective_source_number or ""
),
actor=(effective_source_uuid or effective_source_number or ""),
target_author=str(
(reaction_payload.get("raw") or {}).get("targetAuthorUuid")
or (reaction_payload.get("raw") or {}).get("targetAuthor")
or (reaction_payload.get("raw") or {}).get("targetAuthorNumber")
or (reaction_payload.get("raw") or {}).get(
"targetAuthorNumber"
)
or ""
),
remove=bool(reaction_payload.get("remove")),
@@ -741,9 +755,7 @@ class HandleMessage(Command):
remove=bool(reaction_payload.get("remove")),
upstream_message_id="",
upstream_ts=int(reaction_payload.get("target_ts") or 0),
actor=(
effective_source_uuid or effective_source_number or ""
),
actor=(effective_source_uuid or effective_source_number or ""),
payload=reaction_payload.get("raw") or {},
)
except Exception as exc:
@@ -840,9 +852,7 @@ class HandleMessage(Command):
source_ref={
"upstream_message_id": "",
"upstream_author": str(
effective_source_uuid
or effective_source_number
or ""
effective_source_uuid or effective_source_number or ""
),
"upstream_ts": int(ts or 0),
},
@@ -1134,7 +1144,9 @@ class SignalClient(ClientBase):
if int(message_row.delivered_ts or 0) <= 0:
message_row.delivered_ts = int(result)
update_fields.append("delivered_ts")
if str(message_row.source_message_id or "").strip() != str(result):
if str(message_row.source_message_id or "").strip() != str(
result
):
message_row.source_message_id = str(result)
update_fields.append("source_message_id")
if update_fields:
@@ -1146,9 +1158,11 @@ class SignalClient(ClientBase):
command_id,
{
"ok": True,
"timestamp": int(result)
"timestamp": (
int(result)
if isinstance(result, int)
else int(time.time() * 1000),
else int(time.time() * 1000)
),
},
)
except Exception as exc:
@@ -1248,7 +1262,9 @@ class SignalClient(ClientBase):
if _digits_only(getattr(row, "identifier", "")) in candidate_digits
]
async def _auto_link_single_user_signal_identifier(self, source_uuid: str, source_number: str):
async def _auto_link_single_user_signal_identifier(
self, source_uuid: str, source_number: str
):
owner_rows = await sync_to_async(list)(
PersonIdentifier.objects.filter(service=self.service)
.select_related("user")
@@ -1292,7 +1308,9 @@ class SignalClient(ClientBase):
payload = json.loads(raw_message or "{}")
except Exception:
return
exception_payload = payload.get("exception") if isinstance(payload, dict) else None
exception_payload = (
payload.get("exception") if isinstance(payload, dict) else None
)
if isinstance(exception_payload, dict):
err_type = str(exception_payload.get("type") or "").strip()
err_msg = str(exception_payload.get("message") or "").strip()
@@ -1322,7 +1340,9 @@ class SignalClient(ClientBase):
(envelope.get("timestamp") if isinstance(envelope, dict) else 0)
or int(time.time() * 1000)
),
last_inbound_exception_account=str(payload.get("account") or "").strip(),
last_inbound_exception_account=str(
payload.get("account") or ""
).strip(),
last_inbound_exception_source_uuid=envelope_source_uuid,
last_inbound_exception_source_number=envelope_source_number,
last_inbound_exception_envelope_ts=envelope_ts,
@@ -1346,7 +1366,11 @@ class SignalClient(ClientBase):
raw_text = sync_sent_message.get("message")
if isinstance(raw_text, dict):
text = _extract_signal_text(
{"envelope": {"syncMessage": {"sentMessage": {"message": raw_text}}}},
{
"envelope": {
"syncMessage": {"sentMessage": {"message": raw_text}}
}
},
str(
raw_text.get("message")
or raw_text.get("text")
@@ -1396,9 +1420,15 @@ class SignalClient(ClientBase):
source_service="signal",
actor=(source_uuid or source_number or ""),
target_author=str(
(reaction_payload.get("raw") or {}).get("targetAuthorUuid")
or (reaction_payload.get("raw") or {}).get("targetAuthor")
or (reaction_payload.get("raw") or {}).get("targetAuthorNumber")
(reaction_payload.get("raw") or {}).get(
"targetAuthorUuid"
)
or (reaction_payload.get("raw") or {}).get(
"targetAuthor"
)
or (reaction_payload.get("raw") or {}).get(
"targetAuthorNumber"
)
or ""
),
remove=bool(reaction_payload.get("remove")),
@@ -1505,7 +1535,9 @@ class SignalClient(ClientBase):
source_chat_id = destination_number or destination_uuid or sender_key
reply_ref = reply_sync.extract_reply_ref(self.service, payload)
for identifier in identifiers:
session = await history.get_chat_session(identifier.user, identifier)
session = await history.get_chat_session(
identifier.user, identifier
)
reply_target = await reply_sync.resolve_reply_target(
identifier.user,
session,
@@ -1552,13 +1584,19 @@ class SignalClient(ClientBase):
if not isinstance(data_message, dict):
return
source_uuid = str(envelope.get("sourceUuid") or envelope.get("source") or "").strip()
source_uuid = str(
envelope.get("sourceUuid") or envelope.get("source") or ""
).strip()
source_number = str(envelope.get("sourceNumber") or "").strip()
bot_uuid = str(getattr(self.client, "bot_uuid", "") or "").strip()
bot_phone = str(getattr(self.client, "phone_number", "") or "").strip()
if source_uuid and bot_uuid and source_uuid == bot_uuid:
return
if source_number and bot_phone and _digits_only(source_number) == _digits_only(bot_phone):
if (
source_number
and bot_phone
and _digits_only(source_number) == _digits_only(bot_phone)
):
return
identifiers = await self._resolve_signal_identifiers(source_uuid, source_number)
@@ -1610,14 +1648,18 @@ class SignalClient(ClientBase):
target_author=str(
(reaction_payload.get("raw") or {}).get("targetAuthorUuid")
or (reaction_payload.get("raw") or {}).get("targetAuthor")
or (reaction_payload.get("raw") or {}).get("targetAuthorNumber")
or (reaction_payload.get("raw") or {}).get(
"targetAuthorNumber"
)
or ""
),
remove=bool(reaction_payload.get("remove")),
payload=reaction_payload.get("raw") or {},
)
except Exception as exc:
self.log.warning("signal raw reaction history apply failed: %s", exc)
self.log.warning(
"signal raw reaction history apply failed: %s", exc
)
try:
await self.ur.xmpp.client.apply_external_reaction(
identifier.user,
@@ -1631,7 +1673,9 @@ class SignalClient(ClientBase):
payload=reaction_payload.get("raw") or {},
)
except Exception as exc:
self.log.warning("signal raw reaction relay to XMPP failed: %s", exc)
self.log.warning(
"signal raw reaction relay to XMPP failed: %s", exc
)
transport.update_runtime_state(
self.service,
last_inbound_ok_ts=int(time.time() * 1000),
@@ -1683,7 +1727,9 @@ class SignalClient(ClientBase):
)
return
text = _extract_signal_text(payload, str(data_message.get("message") or "").strip())
text = _extract_signal_text(
payload, str(data_message.get("message") or "").strip()
)
if not text:
return
@@ -1702,7 +1748,11 @@ class SignalClient(ClientBase):
or envelope.get("timestamp")
or ts
).strip()
sender_key = source_uuid or source_number or (identifiers[0].identifier if identifiers else "")
sender_key = (
source_uuid
or source_number
or (identifiers[0].identifier if identifiers else "")
)
source_chat_id = source_number or source_uuid or sender_key
reply_ref = reply_sync.extract_reply_ref(self.service, payload)

View File

@@ -927,7 +927,11 @@ async def send_reaction(
service_key = _service_key(service)
if _capability_checks_enabled() and not supports(service_key, "reactions"):
reason = unsupported_reason(service_key, "reactions")
log.warning("capability-check failed service=%s feature=reactions: %s", service_key, reason)
log.warning(
"capability-check failed service=%s feature=reactions: %s",
service_key,
reason,
)
return False
if not str(emoji or "").strip() and not remove:
return False

View File

@@ -173,9 +173,7 @@ class WhatsAppClient(ClientBase):
if db_dir:
os.makedirs(db_dir, exist_ok=True)
if db_dir and not os.access(db_dir, os.W_OK):
raise PermissionError(
f"session db directory is not writable: {db_dir}"
)
raise PermissionError(f"session db directory is not writable: {db_dir}")
except Exception as exc:
self._publish_state(
connected=False,
@@ -772,9 +770,11 @@ class WhatsAppClient(ClientBase):
command_id,
{
"ok": True,
"timestamp": int(result)
"timestamp": (
int(result)
if isinstance(result, int)
else int(time.time() * 1000),
else int(time.time() * 1000)
),
},
)
self.log.debug(
@@ -1910,9 +1910,7 @@ class WhatsAppClient(ClientBase):
jid_value = self._jid_to_identifier(
self._pluck(group, "JID") or self._pluck(group, "jid")
)
identifier = (
jid_value.split("@", 1)[0].strip() if jid_value else ""
)
identifier = jid_value.split("@", 1)[0].strip() if jid_value else ""
if not identifier:
continue
name = (
@@ -2362,12 +2360,22 @@ class WhatsAppClient(ClientBase):
node = (
self._pluck(message_obj, "reactionMessage")
or self._pluck(message_obj, "reaction_message")
or self._pluck(message_obj, "ephemeralMessage", "message", "reactionMessage")
or self._pluck(message_obj, "ephemeral_message", "message", "reaction_message")
or self._pluck(
message_obj, "ephemeralMessage", "message", "reactionMessage"
)
or self._pluck(
message_obj, "ephemeral_message", "message", "reaction_message"
)
or self._pluck(message_obj, "viewOnceMessage", "message", "reactionMessage")
or self._pluck(message_obj, "view_once_message", "message", "reaction_message")
or self._pluck(message_obj, "viewOnceMessageV2", "message", "reactionMessage")
or self._pluck(message_obj, "view_once_message_v2", "message", "reaction_message")
or self._pluck(
message_obj, "view_once_message", "message", "reaction_message"
)
or self._pluck(
message_obj, "viewOnceMessageV2", "message", "reactionMessage"
)
or self._pluck(
message_obj, "view_once_message_v2", "message", "reaction_message"
)
or self._pluck(
message_obj,
"viewOnceMessageV2Extension",
@@ -2410,7 +2418,9 @@ class WhatsAppClient(ClientBase):
explicit_remove = self._pluck(node, "remove") or self._pluck(node, "isRemove")
if explicit_remove is None:
explicit_remove = self._pluck(node, "is_remove")
remove = bool(explicit_remove) if explicit_remove is not None else bool(not emoji)
remove = (
bool(explicit_remove) if explicit_remove is not None else bool(not emoji)
)
if not target_msg_id:
return None
return {
@@ -2418,7 +2428,11 @@ class WhatsAppClient(ClientBase):
"target_message_id": target_msg_id,
"remove": remove,
"target_ts": int(target_ts or 0),
"raw": self._proto_to_dict(node) or dict(node or {}) if isinstance(node, dict) else {},
"raw": (
self._proto_to_dict(node) or dict(node or {})
if isinstance(node, dict)
else {}
),
}
async def _download_event_media(self, event):
@@ -2760,7 +2774,9 @@ class WhatsAppClient(ClientBase):
or self._pluck(msg_obj, "MessageContextInfo")
or {},
"message": {
"extendedTextMessage": self._pluck(msg_obj, "extendedTextMessage")
"extendedTextMessage": self._pluck(
msg_obj, "extendedTextMessage"
)
or self._pluck(msg_obj, "ExtendedTextMessage")
or {},
"imageMessage": self._pluck(msg_obj, "imageMessage") or {},
@@ -2814,7 +2830,9 @@ class WhatsAppClient(ClientBase):
or {},
"viewOnceMessage": self._pluck(msg_obj, "viewOnceMessage")
or {},
"viewOnceMessageV2": self._pluck(msg_obj, "viewOnceMessageV2")
"viewOnceMessageV2": self._pluck(
msg_obj, "viewOnceMessageV2"
)
or {},
"viewOnceMessageV2Extension": self._pluck(
msg_obj, "viewOnceMessageV2Extension"
@@ -2840,12 +2858,12 @@ class WhatsAppClient(ClientBase):
reply_sync.extract_origin_tag(payload),
)
if self._chat_matches_reply_debug(chat):
info_obj = self._proto_to_dict(self._pluck(event_obj, "Info")) or self._pluck(
event_obj, "Info"
)
raw_obj = self._proto_to_dict(self._pluck(event_obj, "Raw")) or self._pluck(
event_obj, "Raw"
)
info_obj = self._proto_to_dict(
self._pluck(event_obj, "Info")
) or self._pluck(event_obj, "Info")
raw_obj = self._proto_to_dict(
self._pluck(event_obj, "Raw")
) or self._pluck(event_obj, "Raw")
message_meta["wa_reply_debug"] = {
"reply_ref": reply_ref,
"reply_target_id": str(getattr(reply_target, "id", "") or ""),
@@ -3087,9 +3105,11 @@ class WhatsAppClient(ClientBase):
)
matched = False
for candidate in candidates:
candidate_local = str(self._jid_to_identifier(candidate) or "").split(
"@", 1
)[0].strip()
candidate_local = (
str(self._jid_to_identifier(candidate) or "")
.split("@", 1)[0]
.strip()
)
if candidate_local and candidate_local == local:
matched = True
break
@@ -3124,7 +3144,12 @@ class WhatsAppClient(ClientBase):
# WhatsApp group ids are numeric and usually very long (commonly start
# with 120...). Treat those as groups when no explicit mapping exists.
digits = re.sub(r"[^0-9]", "", local)
if digits and digits == local and len(digits) >= 15 and digits.startswith("120"):
if (
digits
and digits == local
and len(digits) >= 15
and digits.startswith("120")
):
return f"{digits}@g.us"
return ""
@@ -3264,7 +3289,9 @@ class WhatsAppClient(ClientBase):
person_identifier = await sync_to_async(
lambda: (
Message.objects.filter(id=legacy_message_id)
.select_related("session__identifier__user", "session__identifier__person")
.select_related(
"session__identifier__user", "session__identifier__person"
)
.first()
)
)()
@@ -3274,7 +3301,9 @@ class WhatsAppClient(ClientBase):
)
if (
person_identifier is not None
and str(getattr(person_identifier, "service", "") or "").strip().lower()
and str(getattr(person_identifier, "service", "") or "")
.strip()
.lower()
!= "whatsapp"
):
person_identifier = None
@@ -3418,6 +3447,8 @@ class WhatsAppClient(ClientBase):
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
ContextInfo,
ExtendedTextMessage,
)
from neonize.proto.waE2E.WAWebProtobufsE2E_pb2 import (
Message as WAProtoMessage,
)

File diff suppressed because it is too large Load Diff

View File

@@ -31,7 +31,9 @@ def chunk_for_transport(text: str, limit: int = 3000) -> list[str]:
return [part for part in parts if part]
async def post_status_in_source(trigger_message: Message, text: str, origin_tag: str) -> bool:
async def post_status_in_source(
trigger_message: Message, text: str, origin_tag: str
) -> bool:
service = str(trigger_message.source_service or "").strip().lower()
if service not in STATUS_VISIBLE_SOURCE_SERVICES:
return False
@@ -76,9 +78,10 @@ async def post_to_channel_binding(
channel_identifier = str(binding_channel_identifier or "").strip()
if service == "web":
session = None
if channel_identifier and channel_identifier == str(
trigger_message.source_chat_id or ""
).strip():
if (
channel_identifier
and channel_identifier == str(trigger_message.source_chat_id or "").strip()
):
session = trigger_message.session
if session is None and channel_identifier:
session = await sync_to_async(
@@ -99,7 +102,8 @@ async def post_to_channel_binding(
ts=int(time.time() * 1000),
custom_author="BOT",
source_service="web",
source_chat_id=channel_identifier or str(trigger_message.source_chat_id or ""),
source_chat_id=channel_identifier
or str(trigger_message.source_chat_id or ""),
message_meta={"origin_tag": origin_tag},
)
return True

View File

@@ -58,9 +58,15 @@ def _effective_bootstrap_scope(
identifier = str(ctx.channel_identifier or "").strip()
if service != "web":
return service, identifier
session_identifier = getattr(getattr(trigger_message, "session", None), "identifier", None)
fallback_service = str(getattr(session_identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(session_identifier, "identifier", "") or "").strip()
session_identifier = getattr(
getattr(trigger_message, "session", None), "identifier", None
)
fallback_service = (
str(getattr(session_identifier, "service", "") or "").strip().lower()
)
fallback_identifier = str(
getattr(session_identifier, "identifier", "") or ""
).strip()
if fallback_service and fallback_identifier and fallback_service != "web":
return fallback_service, fallback_identifier
return service, identifier
@@ -89,7 +95,11 @@ def _ensure_bp_profile(user_id: int) -> CommandProfile:
if str(profile.trigger_token or "").strip() != ".bp":
profile.trigger_token = ".bp"
profile.save(update_fields=["trigger_token", "updated_at"])
for action_type, position in (("extract_bp", 0), ("save_document", 1), ("post_result", 2)):
for action_type, position in (
("extract_bp", 0),
("save_document", 1),
("post_result", 2),
):
action, created = CommandAction.objects.get_or_create(
profile=profile,
action_type=action_type,
@@ -327,7 +337,9 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
return []
if is_mirrored_origin(trigger_message.message_meta):
return []
effective_service, effective_channel = _effective_bootstrap_scope(ctx, trigger_message)
effective_service, effective_channel = _effective_bootstrap_scope(
ctx, trigger_message
)
security_context = CommandSecurityContext(
service=effective_service,
channel_identifier=effective_channel,
@@ -394,7 +406,9 @@ async def process_inbound_message(ctx: CommandContext) -> list[CommandResult]:
result = await handler.execute(ctx)
results.append(result)
except Exception as exc:
log.exception("command execution failed for profile=%s: %s", profile.slug, exc)
log.exception(
"command execution failed for profile=%s: %s", profile.slug, exc
)
results.append(
CommandResult(
ok=False,

View File

@@ -45,14 +45,15 @@ class BPParsedCommand(dict):
return str(self.get("remainder_text") or "")
def parse_bp_subcommand(text: str) -> BPParsedCommand:
body = str(text or "")
if _BP_SET_RANGE_RE.match(body):
return BPParsedCommand(command="set_range", remainder_text="")
match = _BP_SET_RE.match(body)
if match:
return BPParsedCommand(command="set", remainder_text=str(match.group("rest") or "").strip())
return BPParsedCommand(
command="set", remainder_text=str(match.group("rest") or "").strip()
)
return BPParsedCommand(command=None, remainder_text="")
@@ -63,7 +64,9 @@ def bp_subcommands_enabled() -> bool:
return bool(raw)
def bp_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
def bp_trigger_matches(
message_text: str, trigger_token: str, exact_match_only: bool
) -> bool:
body = str(message_text or "").strip()
trigger = str(trigger_token or "").strip()
parsed = parse_bp_subcommand(body)
@@ -144,7 +147,8 @@ class BPCommandHandler(CommandHandler):
"enabled": True,
"generation_mode": "ai" if variant_key == "bp" else "verbatim",
"send_plan_to_egress": "post_result" in action_types,
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source",
"send_status_to_source": str(profile.visibility_mode or "")
== "status_in_source",
"send_status_to_egress": False,
"store_document": True,
}
@@ -224,10 +228,14 @@ class BPCommandHandler(CommandHandler):
ts__lte=int(trigger.ts or 0),
)
.order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person")
.select_related(
"session", "session__identifier", "session__identifier__person"
)
)
def _annotation(self, mode: str, message_count: int, has_addendum: bool = False) -> str:
def _annotation(
self, mode: str, message_count: int, has_addendum: bool = False
) -> str:
if mode == "set" and has_addendum:
return "Generated from 1 message + 1 addendum."
if message_count == 1:
@@ -291,21 +299,29 @@ class BPCommandHandler(CommandHandler):
if anchor is None:
run.status = "failed"
run.error = "bp_set_range_requires_reply_target"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
rows = await self._load_window(trigger, anchor)
deterministic_content = plain_text_blob(rows)
if not deterministic_content.strip():
run.status = "failed"
run.error = "bp_set_range_empty_content"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
if str(policy.get("generation_mode") or "verbatim") == "ai":
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
ai_obj = await sync_to_async(
lambda: AI.objects.filter(user=trigger.user).first()
)()
if ai_obj is None:
run.status = "failed"
run.error = "ai_not_configured"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
prompt = [
{
@@ -329,12 +345,16 @@ class BPCommandHandler(CommandHandler):
except Exception as exc:
run.status = "failed"
run.error = f"bp_ai_failed:{exc}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
if not content:
run.status = "failed"
run.error = "empty_ai_response"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
else:
content = deterministic_content
@@ -360,9 +380,7 @@ class BPCommandHandler(CommandHandler):
elif anchor is not None and remainder:
base = str(anchor.text or "").strip() or "(no text)"
content = (
f"{base}\n"
"--- Addendum (newer message text) ---\n"
f"{remainder}"
f"{base}\n" "--- Addendum (newer message text) ---\n" f"{remainder}"
)
source_ids.extend([str(anchor.id), str(trigger.id)])
has_addendum = True
@@ -373,15 +391,21 @@ class BPCommandHandler(CommandHandler):
else:
run.status = "failed"
run.error = "bp_set_empty_content"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
if str(policy.get("generation_mode") or "verbatim") == "ai":
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
ai_obj = await sync_to_async(
lambda: AI.objects.filter(user=trigger.user).first()
)()
if ai_obj is None:
run.status = "failed"
run.error = "ai_not_configured"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
prompt = [
{
@@ -405,16 +429,22 @@ class BPCommandHandler(CommandHandler):
except Exception as exc:
run.status = "failed"
run.error = f"bp_ai_failed:{exc}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
if not ai_content:
run.status = "failed"
run.error = "empty_ai_response"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
content = ai_content
annotation = self._annotation("set", 1 if not has_addendum else 2, has_addendum)
annotation = self._annotation(
"set", 1 if not has_addendum else 2, has_addendum
)
doc = None
if bool(policy.get("store_document", True)):
doc = await self._persist_document(
@@ -430,7 +460,9 @@ class BPCommandHandler(CommandHandler):
else:
run.status = "failed"
run.error = "bp_unknown_subcommand"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
fanout_stats = {"sent_bindings": 0, "failed_bindings": 0}
@@ -479,7 +511,9 @@ class BPCommandHandler(CommandHandler):
if trigger.reply_to_id is None:
run.status = "failed"
run.error = "bp_requires_reply_target"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
anchor = trigger.reply_to
@@ -488,7 +522,9 @@ class BPCommandHandler(CommandHandler):
rows,
author_rewrites={"USER": "Operator", "BOT": "Assistant"},
)
max_transcript_chars = int(getattr(settings, "BP_MAX_TRANSCRIPT_CHARS", 12000) or 12000)
max_transcript_chars = int(
getattr(settings, "BP_MAX_TRANSCRIPT_CHARS", 12000) or 12000
)
transcript = _clamp_transcript(transcript, max_transcript_chars)
default_template = (
"Business Plan:\n"
@@ -499,7 +535,9 @@ class BPCommandHandler(CommandHandler):
"- Risks"
)
template_text = profile.template_text or default_template
max_template_chars = int(getattr(settings, "BP_MAX_TEMPLATE_CHARS", 5000) or 5000)
max_template_chars = int(
getattr(settings, "BP_MAX_TEMPLATE_CHARS", 5000) or 5000
)
template_text = str(template_text or "")[:max_template_chars]
generation_mode = str(policy.get("generation_mode") or "ai")
if generation_mode == "verbatim":
@@ -507,14 +545,20 @@ class BPCommandHandler(CommandHandler):
if not summary.strip():
run.status = "failed"
run.error = "bp_verbatim_empty_content"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
else:
ai_obj = await sync_to_async(lambda: AI.objects.filter(user=trigger.user).first())()
ai_obj = await sync_to_async(
lambda: AI.objects.filter(user=trigger.user).first()
)()
if ai_obj is None:
run.status = "failed"
run.error = "ai_not_configured"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
prompt = [
@@ -530,13 +574,20 @@ class BPCommandHandler(CommandHandler):
},
]
try:
summary = str(await ai_runner.run_prompt(prompt, ai_obj, operation="command_bp_extract") or "").strip()
summary = str(
await ai_runner.run_prompt(
prompt, ai_obj, operation="command_bp_extract"
)
or ""
).strip()
if not summary:
raise RuntimeError("empty_ai_response")
except Exception as exc:
run.status = "failed"
run.error = f"bp_ai_failed:{exc}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="failed", error=run.error)
annotation = self._annotation("legacy", len(rows))
@@ -588,23 +639,31 @@ class BPCommandHandler(CommandHandler):
async def execute(self, ctx: CommandContext) -> CommandResult:
trigger = await sync_to_async(
lambda: Message.objects.select_related("user", "session").filter(id=ctx.message_id).first()
lambda: Message.objects.select_related("user", "session")
.filter(id=ctx.message_id)
.first()
)()
if trigger is None:
return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async(
lambda: trigger.user.commandprofile_set.filter(slug=self.slug, enabled=True).first()
lambda: trigger.user.commandprofile_set.filter(
slug=self.slug, enabled=True
).first()
)()
if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing")
actions = await sync_to_async(list)(
CommandAction.objects.filter(profile=profile, enabled=True).order_by("position", "id")
CommandAction.objects.filter(profile=profile, enabled=True).order_by(
"position", "id"
)
)
action_types = {row.action_type for row in actions}
if "extract_bp" not in action_types:
return CommandResult(ok=False, status="skipped", error="extract_bp_disabled")
return CommandResult(
ok=False, status="skipped", error="extract_bp_disabled"
)
run, created = await sync_to_async(CommandRun.objects.get_or_create)(
profile=profile,
@@ -612,7 +671,11 @@ class BPCommandHandler(CommandHandler):
defaults={"user": trigger.user, "status": "running"},
)
if not created and run.status in {"ok", "running"}:
return CommandResult(ok=True, status="ok", payload={"document_id": str(run.result_ref_id or "")})
return CommandResult(
ok=True,
status="ok",
payload={"document_id": str(run.result_ref_id or "")},
)
run.status = "running"
run.error = ""
@@ -627,7 +690,9 @@ class BPCommandHandler(CommandHandler):
if not bool(policy.get("enabled")):
run.status = "skipped"
run.error = f"variant_disabled:{variant_key}"
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
return CommandResult(ok=False, status="skipped", error=run.error)
parsed = parse_bp_subcommand(ctx.message_text)

View File

@@ -20,8 +20,8 @@ from core.models import (
TaskProject,
TaskProviderConfig,
)
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
_CLAUDE_DEFAULT_RE = re.compile(
r"^\s*(?:\.claude\b|#claude#?)(?P<body>.*)$",
@@ -31,7 +31,9 @@ _CLAUDE_PLAN_RE = re.compile(
r"^\s*(?:\.claude\s+plan\b|#claude\s+plan#?)(?P<body>.*)$",
re.IGNORECASE | re.DOTALL,
)
_CLAUDE_STATUS_RE = re.compile(r"^\s*(?:\.claude\s+status\b|#claude\s+status#?)\s*$", re.IGNORECASE)
_CLAUDE_STATUS_RE = re.compile(
r"^\s*(?:\.claude\s+status\b|#claude\s+status#?)\s*$", re.IGNORECASE
)
_CLAUDE_APPROVE_DENY_RE = re.compile(
r"^\s*(?:\.claude|#claude)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
re.IGNORECASE,
@@ -83,7 +85,9 @@ def parse_claude_command(text: str) -> ClaudeParsedCommand:
return ClaudeParsedCommand(command=None, body_text="", approval_key="")
def claude_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
def claude_trigger_matches(
message_text: str, trigger_token: str, exact_match_only: bool
) -> bool:
body = str(message_text or "").strip()
parsed = parse_claude_command(body)
if parsed.command:
@@ -103,7 +107,9 @@ class ClaudeCommandHandler(CommandHandler):
async def _load_trigger(self, message_id: str) -> Message | None:
return await sync_to_async(
lambda: Message.objects.select_related("user", "session", "session__identifier", "reply_to")
lambda: Message.objects.select_related(
"user", "session", "session__identifier", "reply_to"
)
.filter(id=message_id)
.first()
)()
@@ -114,11 +120,18 @@ class ClaudeCommandHandler(CommandHandler):
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
if service == "web" and fallback_service and fallback_identifier and fallback_service != "web":
if (
service == "web"
and fallback_service
and fallback_identifier
and fallback_service != "web"
):
return fallback_service, fallback_identifier
return service or "web", channel
async def _mapped_sources(self, user, service: str, channel: str) -> list[ChatTaskSource]:
async def _mapped_sources(
self, user, service: str, channel: str
) -> list[ChatTaskSource]:
variants = channel_variants(service, channel)
if not variants:
return []
@@ -131,7 +144,9 @@ class ClaudeCommandHandler(CommandHandler):
).select_related("project", "epic")
)
async def _linked_task_from_reply(self, user, reply_to: Message | None) -> DerivedTask | None:
async def _linked_task_from_reply(
self, user, reply_to: Message | None
) -> DerivedTask | None:
if reply_to is None:
return None
by_origin = await sync_to_async(
@@ -143,7 +158,9 @@ class ClaudeCommandHandler(CommandHandler):
if by_origin is not None:
return by_origin
return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, events__source_message=reply_to)
lambda: DerivedTask.objects.filter(
user=user, events__source_message=reply_to
)
.select_related("project", "epic")
.order_by("-created_at")
.first()
@@ -164,10 +181,14 @@ class ClaudeCommandHandler(CommandHandler):
return ""
return str(m.group(1) or "").strip()
async def _resolve_task(self, user, reference_code: str, reply_task: DerivedTask | None) -> DerivedTask | None:
async def _resolve_task(
self, user, reference_code: str, reply_task: DerivedTask | None
) -> DerivedTask | None:
if reference_code:
return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, reference_code=reference_code)
lambda: DerivedTask.objects.filter(
user=user, reference_code=reference_code
)
.select_related("project", "epic")
.order_by("-created_at")
.first()
@@ -190,7 +211,9 @@ class ClaudeCommandHandler(CommandHandler):
return reply_task.project, ""
if project_token:
project = await sync_to_async(
lambda: TaskProject.objects.filter(user=user, name__iexact=project_token).first()
lambda: TaskProject.objects.filter(
user=user, name__iexact=project_token
).first()
)()
if project is not None:
return project, ""
@@ -199,20 +222,31 @@ class ClaudeCommandHandler(CommandHandler):
mapped = await self._mapped_sources(user, service, channel)
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
if len(project_ids) == 1:
project = next((row.project for row in mapped if str(row.project_id) == project_ids[0]), None)
project = next(
(
row.project
for row in mapped
if str(row.project_id) == project_ids[0]
),
None,
)
return project, ""
if len(project_ids) > 1:
return None, "project_required:[project:Name]"
return None, "project_unresolved"
async def _post_source_status(self, trigger: Message, text: str, suffix: str) -> None:
async def _post_source_status(
self, trigger: Message, text: str, suffix: str
) -> None:
await post_status_in_source(
trigger_message=trigger,
text=text,
origin_tag=f"claude-status:{suffix}",
)
async def _run_status(self, trigger: Message, service: str, channel: str, project: TaskProject | None) -> CommandResult:
async def _run_status(
self, trigger: Message, service: str, channel: str, project: TaskProject | None
) -> CommandResult:
def _load_runs():
qs = CodexRun.objects.filter(user=trigger.user)
if service:
@@ -225,7 +259,9 @@ class ClaudeCommandHandler(CommandHandler):
runs = await sync_to_async(_load_runs)()
if not runs:
await self._post_source_status(trigger, "[claude] no recent runs for this scope.", "empty")
await self._post_source_status(
trigger, "[claude] no recent runs for this scope.", "empty"
)
return CommandResult(ok=True, status="ok", payload={"count": 0})
lines = ["[claude] recent runs:"]
for row in runs:
@@ -249,24 +285,38 @@ class ClaudeCommandHandler(CommandHandler):
).first()
)()
settings_payload = dict(getattr(cfg, "settings", {}) or {})
approver_service = str(settings_payload.get("approver_service") or "").strip().lower()
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
approver_service = (
str(settings_payload.get("approver_service") or "").strip().lower()
)
approver_identifier = str(
settings_payload.get("approver_identifier") or ""
).strip()
if not approver_service or not approver_identifier:
return CommandResult(ok=False, status="failed", error="approver_channel_not_configured")
return CommandResult(
ok=False, status="failed", error="approver_channel_not_configured"
)
if str(current_service or "").strip().lower() != approver_service or str(current_channel or "").strip() not in set(
channel_variants(approver_service, approver_identifier)
):
return CommandResult(ok=False, status="failed", error="approval_command_not_allowed_in_this_channel")
if str(current_service or "").strip().lower() != approver_service or str(
current_channel or ""
).strip() not in set(channel_variants(approver_service, approver_identifier)):
return CommandResult(
ok=False,
status="failed",
error="approval_command_not_allowed_in_this_channel",
)
approval_key = parsed.approval_key
request = await sync_to_async(
lambda: CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event")
lambda: CodexPermissionRequest.objects.select_related(
"codex_run", "external_sync_event"
)
.filter(user=trigger.user, approval_key=approval_key)
.first()
)()
if request is None:
return CommandResult(ok=False, status="failed", error="approval_key_not_found")
return CommandResult(
ok=False, status="failed", error="approval_key_not_found"
)
now = timezone.now()
if parsed.command == "approve":
@@ -283,14 +333,20 @@ class ClaudeCommandHandler(CommandHandler):
]
)
if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)(
await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="ok",
error="",
)
run = request.codex_run
run.status = "approved_waiting_resume"
run.error = ""
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
source_service = str(run.source_service or "")
source_channel = str(run.source_channel or "")
resume_payload = dict(request.resume_payload or {})
@@ -302,14 +358,18 @@ class ClaudeCommandHandler(CommandHandler):
provider_payload["source_service"] = source_service
provider_payload["source_channel"] = source_channel
event_action = resume_action
resume_idempotency_key = str(resume_payload.get("idempotency_key") or "").strip()
resume_idempotency_key = str(
resume_payload.get("idempotency_key") or ""
).strip()
resume_event_key = (
resume_idempotency_key
if resume_idempotency_key
else f"{self._approval_prefix}:{approval_key}:approved"
)
else:
provider_payload = dict(run.request_payload.get("provider_payload") or {})
provider_payload = dict(
run.request_payload.get("provider_payload") or {}
)
provider_payload.update(
{
"mode": "approval_response",
@@ -337,17 +397,30 @@ class ClaudeCommandHandler(CommandHandler):
"error": "",
},
)
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "approved"})
return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "approved"},
)
request.status = "denied"
request.resolved_at = now
request.resolved_by_identifier = current_channel
request.resolution_note = "denied via claude command"
await sync_to_async(request.save)(
update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"]
update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
)
if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)(
await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="failed",
error="approval_denied",
)
@@ -374,7 +447,11 @@ class ClaudeCommandHandler(CommandHandler):
"error": "approval_denied",
},
)
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "denied"})
return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "denied"},
)
async def _create_submission(
self,
@@ -391,7 +468,9 @@ class ClaudeCommandHandler(CommandHandler):
).first()
)()
if cfg is None:
return CommandResult(ok=False, status="failed", error="provider_disabled_or_missing")
return CommandResult(
ok=False, status="failed", error="provider_disabled_or_missing"
)
service, channel = self._effective_scope(trigger)
external_chat_id = await sync_to_async(resolve_external_chat_id)(
@@ -418,7 +497,9 @@ class ClaudeCommandHandler(CommandHandler):
if mode == "plan":
anchor = trigger.reply_to
if anchor is None:
return CommandResult(ok=False, status="failed", error="reply_required_for_claude_plan")
return CommandResult(
ok=False, status="failed", error="reply_required_for_claude_plan"
)
rows = await sync_to_async(list)(
Message.objects.filter(
user=trigger.user,
@@ -427,7 +508,9 @@ class ClaudeCommandHandler(CommandHandler):
ts__lte=int(trigger.ts or 0),
)
.order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person")
.select_related(
"session", "session__identifier", "session__identifier__person"
)
)
payload["reply_context"] = {
"anchor_message_id": str(anchor.id),
@@ -446,12 +529,18 @@ class ClaudeCommandHandler(CommandHandler):
source_channel=channel,
external_chat_id=external_chat_id,
status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": dict(payload)},
request_payload={
"action": "append_update",
"provider_payload": dict(payload),
},
result_payload={},
error="",
)
payload["codex_run_id"] = str(run.id)
run.request_payload = {"action": "append_update", "provider_payload": dict(payload)}
run.request_payload = {
"action": "append_update",
"provider_payload": dict(payload),
}
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
idempotency_key = f"claude_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
@@ -476,20 +565,26 @@ class ClaudeCommandHandler(CommandHandler):
return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async(
lambda: CommandProfile.objects.filter(user=trigger.user, slug=self.slug, enabled=True).first()
lambda: CommandProfile.objects.filter(
user=trigger.user, slug=self.slug, enabled=True
).first()
)()
if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing")
parsed = parse_claude_command(ctx.message_text)
if not parsed.command:
return CommandResult(ok=False, status="skipped", error="claude_command_not_matched")
return CommandResult(
ok=False, status="skipped", error="claude_command_not_matched"
)
service, channel = self._effective_scope(trigger)
if parsed.command == "status":
project = None
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
reply_task = await self._linked_task_from_reply(
trigger.user, trigger.reply_to
)
if reply_task is not None:
project = reply_task.project
return await self._run_status(trigger, service, channel, project)
@@ -507,7 +602,9 @@ class ClaudeCommandHandler(CommandHandler):
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
task = await self._resolve_task(trigger.user, reference_code, reply_task)
if task is None:
return CommandResult(ok=False, status="failed", error="task_target_required")
return CommandResult(
ok=False, status="failed", error="task_target_required"
)
project, project_error = await self._resolve_project(
user=trigger.user,
@@ -518,7 +615,9 @@ class ClaudeCommandHandler(CommandHandler):
project_token=project_token,
)
if project is None:
return CommandResult(ok=False, status="failed", error=project_error or "project_unresolved")
return CommandResult(
ok=False, status="failed", error=project_error or "project_unresolved"
)
mode = "plan" if parsed.command == "plan" else "default"
return await self._create_submission(

View File

@@ -20,8 +20,8 @@ from core.models import (
TaskProject,
TaskProviderConfig,
)
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import channel_variants, resolve_external_chat_id
_CODEX_DEFAULT_RE = re.compile(
r"^\s*(?:\.codex\b|#codex#?)(?P<body>.*)$",
@@ -31,7 +31,9 @@ _CODEX_PLAN_RE = re.compile(
r"^\s*(?:\.codex\s+plan\b|#codex\s+plan#?)(?P<body>.*)$",
re.IGNORECASE | re.DOTALL,
)
_CODEX_STATUS_RE = re.compile(r"^\s*(?:\.codex\s+status\b|#codex\s+status#?)\s*$", re.IGNORECASE)
_CODEX_STATUS_RE = re.compile(
r"^\s*(?:\.codex\s+status\b|#codex\s+status#?)\s*$", re.IGNORECASE
)
_CODEX_APPROVE_DENY_RE = re.compile(
r"^\s*(?:\.codex|#codex)\s+(?P<action>approve|deny)\s+(?P<approval_key>[A-Za-z0-9._:-]+)#?\s*$",
re.IGNORECASE,
@@ -55,7 +57,6 @@ class CodexParsedCommand(dict):
return str(self.get("approval_key") or "")
def parse_codex_command(text: str) -> CodexParsedCommand:
body = str(text or "")
m = _CODEX_APPROVE_DENY_RE.match(body)
@@ -84,7 +85,9 @@ def parse_codex_command(text: str) -> CodexParsedCommand:
return CodexParsedCommand(command=None, body_text="", approval_key="")
def codex_trigger_matches(message_text: str, trigger_token: str, exact_match_only: bool) -> bool:
def codex_trigger_matches(
message_text: str, trigger_token: str, exact_match_only: bool
) -> bool:
body = str(message_text or "").strip()
parsed = parse_codex_command(body)
if parsed.command:
@@ -102,7 +105,9 @@ class CodexCommandHandler(CommandHandler):
async def _load_trigger(self, message_id: str) -> Message | None:
return await sync_to_async(
lambda: Message.objects.select_related("user", "session", "session__identifier", "reply_to")
lambda: Message.objects.select_related(
"user", "session", "session__identifier", "reply_to"
)
.filter(id=message_id)
.first()
)()
@@ -113,11 +118,18 @@ class CodexCommandHandler(CommandHandler):
identifier = getattr(getattr(trigger, "session", None), "identifier", None)
fallback_service = str(getattr(identifier, "service", "") or "").strip().lower()
fallback_identifier = str(getattr(identifier, "identifier", "") or "").strip()
if service == "web" and fallback_service and fallback_identifier and fallback_service != "web":
if (
service == "web"
and fallback_service
and fallback_identifier
and fallback_service != "web"
):
return fallback_service, fallback_identifier
return service or "web", channel
async def _mapped_sources(self, user, service: str, channel: str) -> list[ChatTaskSource]:
async def _mapped_sources(
self, user, service: str, channel: str
) -> list[ChatTaskSource]:
variants = channel_variants(service, channel)
if not variants:
return []
@@ -130,7 +142,9 @@ class CodexCommandHandler(CommandHandler):
).select_related("project", "epic")
)
async def _linked_task_from_reply(self, user, reply_to: Message | None) -> DerivedTask | None:
async def _linked_task_from_reply(
self, user, reply_to: Message | None
) -> DerivedTask | None:
if reply_to is None:
return None
by_origin = await sync_to_async(
@@ -142,7 +156,9 @@ class CodexCommandHandler(CommandHandler):
if by_origin is not None:
return by_origin
return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, events__source_message=reply_to)
lambda: DerivedTask.objects.filter(
user=user, events__source_message=reply_to
)
.select_related("project", "epic")
.order_by("-created_at")
.first()
@@ -163,10 +179,14 @@ class CodexCommandHandler(CommandHandler):
return ""
return str(m.group(1) or "").strip()
async def _resolve_task(self, user, reference_code: str, reply_task: DerivedTask | None) -> DerivedTask | None:
async def _resolve_task(
self, user, reference_code: str, reply_task: DerivedTask | None
) -> DerivedTask | None:
if reference_code:
return await sync_to_async(
lambda: DerivedTask.objects.filter(user=user, reference_code=reference_code)
lambda: DerivedTask.objects.filter(
user=user, reference_code=reference_code
)
.select_related("project", "epic")
.order_by("-created_at")
.first()
@@ -189,7 +209,9 @@ class CodexCommandHandler(CommandHandler):
return reply_task.project, ""
if project_token:
project = await sync_to_async(
lambda: TaskProject.objects.filter(user=user, name__iexact=project_token).first()
lambda: TaskProject.objects.filter(
user=user, name__iexact=project_token
).first()
)()
if project is not None:
return project, ""
@@ -198,20 +220,31 @@ class CodexCommandHandler(CommandHandler):
mapped = await self._mapped_sources(user, service, channel)
project_ids = sorted({str(row.project_id) for row in mapped if row.project_id})
if len(project_ids) == 1:
project = next((row.project for row in mapped if str(row.project_id) == project_ids[0]), None)
project = next(
(
row.project
for row in mapped
if str(row.project_id) == project_ids[0]
),
None,
)
return project, ""
if len(project_ids) > 1:
return None, "project_required:[project:Name]"
return None, "project_unresolved"
async def _post_source_status(self, trigger: Message, text: str, suffix: str) -> None:
async def _post_source_status(
self, trigger: Message, text: str, suffix: str
) -> None:
await post_status_in_source(
trigger_message=trigger,
text=text,
origin_tag=f"codex-status:{suffix}",
)
async def _run_status(self, trigger: Message, service: str, channel: str, project: TaskProject | None) -> CommandResult:
async def _run_status(
self, trigger: Message, service: str, channel: str, project: TaskProject | None
) -> CommandResult:
def _load_runs():
qs = CodexRun.objects.filter(user=trigger.user)
if service:
@@ -224,7 +257,9 @@ class CodexCommandHandler(CommandHandler):
runs = await sync_to_async(_load_runs)()
if not runs:
await self._post_source_status(trigger, "[codex] no recent runs for this scope.", "empty")
await self._post_source_status(
trigger, "[codex] no recent runs for this scope.", "empty"
)
return CommandResult(ok=True, status="ok", payload={"count": 0})
lines = ["[codex] recent runs:"]
for row in runs:
@@ -243,27 +278,43 @@ class CodexCommandHandler(CommandHandler):
current_channel: str,
) -> CommandResult:
cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=trigger.user, provider="codex_cli").first()
lambda: TaskProviderConfig.objects.filter(
user=trigger.user, provider="codex_cli"
).first()
)()
settings_payload = dict(getattr(cfg, "settings", {}) or {})
approver_service = str(settings_payload.get("approver_service") or "").strip().lower()
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
approver_service = (
str(settings_payload.get("approver_service") or "").strip().lower()
)
approver_identifier = str(
settings_payload.get("approver_identifier") or ""
).strip()
if not approver_service or not approver_identifier:
return CommandResult(ok=False, status="failed", error="approver_channel_not_configured")
return CommandResult(
ok=False, status="failed", error="approver_channel_not_configured"
)
if str(current_service or "").strip().lower() != approver_service or str(current_channel or "").strip() not in set(
channel_variants(approver_service, approver_identifier)
):
return CommandResult(ok=False, status="failed", error="approval_command_not_allowed_in_this_channel")
if str(current_service or "").strip().lower() != approver_service or str(
current_channel or ""
).strip() not in set(channel_variants(approver_service, approver_identifier)):
return CommandResult(
ok=False,
status="failed",
error="approval_command_not_allowed_in_this_channel",
)
approval_key = parsed.approval_key
request = await sync_to_async(
lambda: CodexPermissionRequest.objects.select_related("codex_run", "external_sync_event")
lambda: CodexPermissionRequest.objects.select_related(
"codex_run", "external_sync_event"
)
.filter(user=trigger.user, approval_key=approval_key)
.first()
)()
if request is None:
return CommandResult(ok=False, status="failed", error="approval_key_not_found")
return CommandResult(
ok=False, status="failed", error="approval_key_not_found"
)
now = timezone.now()
if parsed.command == "approve":
@@ -280,14 +331,20 @@ class CodexCommandHandler(CommandHandler):
]
)
if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)(
await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="ok",
error="",
)
run = request.codex_run
run.status = "approved_waiting_resume"
run.error = ""
await sync_to_async(run.save)(update_fields=["status", "error", "updated_at"])
await sync_to_async(run.save)(
update_fields=["status", "error", "updated_at"]
)
source_service = str(run.source_service or "")
source_channel = str(run.source_channel or "")
resume_payload = dict(request.resume_payload or {})
@@ -299,14 +356,18 @@ class CodexCommandHandler(CommandHandler):
provider_payload["source_service"] = source_service
provider_payload["source_channel"] = source_channel
event_action = resume_action
resume_idempotency_key = str(resume_payload.get("idempotency_key") or "").strip()
resume_idempotency_key = str(
resume_payload.get("idempotency_key") or ""
).strip()
resume_event_key = (
resume_idempotency_key
if resume_idempotency_key
else f"codex_approval:{approval_key}:approved"
)
else:
provider_payload = dict(run.request_payload.get("provider_payload") or {})
provider_payload = dict(
run.request_payload.get("provider_payload") or {}
)
provider_payload.update(
{
"mode": "approval_response",
@@ -334,17 +395,30 @@ class CodexCommandHandler(CommandHandler):
"error": "",
},
)
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "approved"})
return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "approved"},
)
request.status = "denied"
request.resolved_at = now
request.resolved_by_identifier = current_channel
request.resolution_note = "denied via command"
await sync_to_async(request.save)(
update_fields=["status", "resolved_at", "resolved_by_identifier", "resolution_note"]
update_fields=[
"status",
"resolved_at",
"resolved_by_identifier",
"resolution_note",
]
)
if request.external_sync_event_id:
await sync_to_async(ExternalSyncEvent.objects.filter(id=request.external_sync_event_id).update)(
await sync_to_async(
ExternalSyncEvent.objects.filter(
id=request.external_sync_event_id
).update
)(
status="failed",
error="approval_denied",
)
@@ -371,7 +445,11 @@ class CodexCommandHandler(CommandHandler):
"error": "approval_denied",
},
)
return CommandResult(ok=True, status="ok", payload={"approval_key": approval_key, "resolution": "denied"})
return CommandResult(
ok=True,
status="ok",
payload={"approval_key": approval_key, "resolution": "denied"},
)
async def _create_submission(
self,
@@ -383,10 +461,14 @@ class CodexCommandHandler(CommandHandler):
project: TaskProject,
) -> CommandResult:
cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=trigger.user, provider="codex_cli", enabled=True).first()
lambda: TaskProviderConfig.objects.filter(
user=trigger.user, provider="codex_cli", enabled=True
).first()
)()
if cfg is None:
return CommandResult(ok=False, status="failed", error="provider_disabled_or_missing")
return CommandResult(
ok=False, status="failed", error="provider_disabled_or_missing"
)
service, channel = self._effective_scope(trigger)
external_chat_id = await sync_to_async(resolve_external_chat_id)(
@@ -413,7 +495,9 @@ class CodexCommandHandler(CommandHandler):
if mode == "plan":
anchor = trigger.reply_to
if anchor is None:
return CommandResult(ok=False, status="failed", error="reply_required_for_codex_plan")
return CommandResult(
ok=False, status="failed", error="reply_required_for_codex_plan"
)
rows = await sync_to_async(list)(
Message.objects.filter(
user=trigger.user,
@@ -422,7 +506,9 @@ class CodexCommandHandler(CommandHandler):
ts__lte=int(trigger.ts or 0),
)
.order_by("ts")
.select_related("session", "session__identifier", "session__identifier__person")
.select_related(
"session", "session__identifier", "session__identifier__person"
)
)
payload["reply_context"] = {
"anchor_message_id": str(anchor.id),
@@ -441,12 +527,18 @@ class CodexCommandHandler(CommandHandler):
source_channel=channel,
external_chat_id=external_chat_id,
status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": dict(payload)},
request_payload={
"action": "append_update",
"provider_payload": dict(payload),
},
result_payload={},
error="",
)
payload["codex_run_id"] = str(run.id)
run.request_payload = {"action": "append_update", "provider_payload": dict(payload)}
run.request_payload = {
"action": "append_update",
"provider_payload": dict(payload),
}
await sync_to_async(run.save)(update_fields=["request_payload", "updated_at"])
idempotency_key = f"codex_cmd:{trigger.id}:{mode}:{task.id}:{hashlib.sha1(str(body_text or '').encode('utf-8')).hexdigest()[:12]}"
@@ -471,20 +563,26 @@ class CodexCommandHandler(CommandHandler):
return CommandResult(ok=False, status="failed", error="trigger_not_found")
profile = await sync_to_async(
lambda: CommandProfile.objects.filter(user=trigger.user, slug=self.slug, enabled=True).first()
lambda: CommandProfile.objects.filter(
user=trigger.user, slug=self.slug, enabled=True
).first()
)()
if profile is None:
return CommandResult(ok=False, status="skipped", error="profile_missing")
parsed = parse_codex_command(ctx.message_text)
if not parsed.command:
return CommandResult(ok=False, status="skipped", error="codex_command_not_matched")
return CommandResult(
ok=False, status="skipped", error="codex_command_not_matched"
)
service, channel = self._effective_scope(trigger)
if parsed.command == "status":
project = None
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
reply_task = await self._linked_task_from_reply(
trigger.user, trigger.reply_to
)
if reply_task is not None:
project = reply_task.project
return await self._run_status(trigger, service, channel, project)
@@ -502,7 +600,9 @@ class CodexCommandHandler(CommandHandler):
reply_task = await self._linked_task_from_reply(trigger.user, trigger.reply_to)
task = await self._resolve_task(trigger.user, reference_code, reply_task)
if task is None:
return CommandResult(ok=False, status="failed", error="task_target_required")
return CommandResult(
ok=False, status="failed", error="task_target_required"
)
project, project_error = await self._resolve_project(
user=trigger.user,
@@ -513,7 +613,9 @@ class CodexCommandHandler(CommandHandler):
project_token=project_token,
)
if project is None:
return CommandResult(ok=False, status="failed", error=project_error or "project_unresolved")
return CommandResult(
ok=False, status="failed", error=project_error or "project_unresolved"
)
mode = "plan" if parsed.command == "plan" else "default"
return await self._create_submission(

View File

@@ -32,7 +32,8 @@ def _legacy_defaults(profile: CommandProfile, post_result_enabled: bool) -> dict
"enabled": True,
"generation_mode": "ai",
"send_plan_to_egress": bool(post_result_enabled),
"send_status_to_source": str(profile.visibility_mode or "") == "status_in_source",
"send_status_to_source": str(profile.visibility_mode or "")
== "status_in_source",
"send_status_to_egress": False,
"store_document": True,
}
@@ -56,7 +57,9 @@ def ensure_variant_policies_for_profile(
*,
action_rows: Iterable[CommandAction] | None = None,
) -> dict[str, CommandVariantPolicy]:
actions = list(action_rows) if action_rows is not None else list(profile.actions.all())
actions = (
list(action_rows) if action_rows is not None else list(profile.actions.all())
)
post_result_enabled = any(
row.action_type == "post_result" and bool(row.enabled) for row in actions
)
@@ -91,7 +94,9 @@ def ensure_variant_policies_for_profile(
return result
def load_variant_policy(profile: CommandProfile, variant_key: str) -> CommandVariantPolicy | None:
def load_variant_policy(
profile: CommandProfile, variant_key: str
) -> CommandVariantPolicy | None:
key = str(variant_key or "").strip()
if not key:
return None

View File

@@ -27,6 +27,7 @@ def settings_hierarchy_nav(request):
ai_models_href = reverse("ai_models")
ai_traces_href = reverse("ai_execution_log")
commands_href = reverse("command_routing")
business_plans_href = reverse("business_plan_inbox")
tasks_href = reverse("tasks_settings")
translation_href = reverse("translation_settings")
availability_href = reverse("availability_settings")
@@ -55,6 +56,8 @@ def settings_hierarchy_nav(request):
modules_routes = {
"modules_settings",
"command_routing",
"business_plan_inbox",
"business_plan_editor",
"tasks_settings",
"translation_settings",
"availability_settings",
@@ -106,7 +109,12 @@ def settings_hierarchy_nav(request):
"title": "Modules",
"tabs": [
_tab("Commands", commands_href, path == commands_href),
_tab("Tasks", tasks_href, path == tasks_href),
_tab(
"Business Plans",
business_plans_href,
url_name in {"business_plan_inbox", "business_plan_editor"},
),
_tab("Task Automation", tasks_href, path == tasks_href),
_tab("Translation", translation_href, path == translation_href),
_tab("Availability", availability_href, path == availability_href),
],

View File

@@ -24,7 +24,6 @@ async def init_mysql_pool():
async def close_mysql_pool():
"""Close the MySQL connection pool properly."""
global mysql_pool
if mysql_pool:
mysql_pool.close()
await mysql_pool.wait_closed()

View File

@@ -15,8 +15,12 @@ def event_ledger_enabled() -> bool:
def event_ledger_status() -> dict:
return {
"event_ledger_dual_write": bool(getattr(settings, "EVENT_LEDGER_DUAL_WRITE", False)),
"event_primary_write_path": bool(getattr(settings, "EVENT_PRIMARY_WRITE_PATH", False)),
"event_ledger_dual_write": bool(
getattr(settings, "EVENT_LEDGER_DUAL_WRITE", False)
),
"event_primary_write_path": bool(
getattr(settings, "EVENT_PRIMARY_WRITE_PATH", False)
),
}
@@ -61,9 +65,7 @@ def append_event_sync(
if not normalized_type:
raise ValueError("event_type is required")
candidates = {
str(choice[0]) for choice in ConversationEvent.EVENT_TYPE_CHOICES
}
candidates = {str(choice[0]) for choice in ConversationEvent.EVENT_TYPE_CHOICES}
if normalized_type not in candidates:
raise ValueError(f"unsupported event_type: {normalized_type}")

View File

@@ -90,7 +90,9 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
order.append(message_id)
state.ts = _safe_int(payload.get("message_ts"), _safe_int(event.ts))
state.text = str(payload.get("text") or state.text or "")
delivered_default = _safe_int(payload.get("delivered_ts"), _safe_int(event.ts))
delivered_default = _safe_int(
payload.get("delivered_ts"), _safe_int(event.ts)
)
if state.delivered_ts is None:
state.delivered_ts = delivered_default or None
continue
@@ -111,7 +113,11 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
continue
if event_type in {"reaction_added", "reaction_removed"}:
source_service = str(payload.get("source_service") or event.origin_transport or "").strip().lower()
source_service = (
str(payload.get("source_service") or event.origin_transport or "")
.strip()
.lower()
)
actor = str(payload.get("actor") or event.actor_identifier or "").strip()
emoji = str(payload.get("emoji") or "").strip()
if not source_service and not actor and not emoji:
@@ -121,7 +127,9 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
"source_service": source_service,
"actor": actor,
"emoji": emoji,
"removed": bool(event_type == "reaction_removed" or payload.get("remove")),
"removed": bool(
event_type == "reaction_removed" or payload.get("remove")
),
}
output = []
@@ -135,12 +143,12 @@ def project_session_from_events(session: ChatSession) -> list[dict]:
"ts": int(state.ts or 0),
"text": str(state.text or ""),
"delivered_ts": (
int(state.delivered_ts)
if state.delivered_ts is not None
else None
int(state.delivered_ts) if state.delivered_ts is not None else None
),
"read_ts": int(state.read_ts) if state.read_ts is not None else None,
"reactions": _normalize_reactions(list((state.reactions or {}).values())),
"reactions": _normalize_reactions(
list((state.reactions or {}).values())
),
}
)
return output
@@ -182,7 +190,9 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
cause_samples = {key: [] for key in cause_counts.keys()}
cause_sample_limit = min(5, max(0, int(detail_limit)))
def _record_detail(message_id: str, issue: str, cause: str, extra: dict | None = None):
def _record_detail(
message_id: str, issue: str, cause: str, extra: dict | None = None
):
if cause in cause_counts:
cause_counts[cause] += 1
row = {"message_id": message_id, "issue": issue, "cause": cause}
@@ -224,13 +234,10 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
db_delivered_ts = db_row.get("delivered_ts")
projected_delivered_ts = projected.get("delivered_ts")
if (
(db_delivered_ts is None) != (projected_delivered_ts is None)
or (
if (db_delivered_ts is None) != (projected_delivered_ts is None) or (
db_delivered_ts is not None
and projected_delivered_ts is not None
and int(db_delivered_ts) != int(projected_delivered_ts)
)
):
counters["delivered_ts_mismatch"] += 1
_record_detail(
@@ -245,13 +252,10 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
db_read_ts = db_row.get("read_ts")
projected_read_ts = projected.get("read_ts")
if (
(db_read_ts is None) != (projected_read_ts is None)
or (
if (db_read_ts is None) != (projected_read_ts is None) or (
db_read_ts is not None
and projected_read_ts is not None
and int(db_read_ts) != int(projected_read_ts)
)
):
counters["read_ts_mismatch"] += 1
_record_detail(
@@ -264,12 +268,19 @@ def shadow_compare_session(session: ChatSession, detail_limit: int = 50) -> dict
db_reactions = _normalize_reactions(
list((db_row.get("receipt_payload") or {}).get("reactions") or [])
)
projected_reactions = _normalize_reactions(list(projected.get("reactions") or []))
projected_reactions = _normalize_reactions(
list(projected.get("reactions") or [])
)
if db_reactions != projected_reactions:
counters["reactions_mismatch"] += 1
cause = "payload_normalization_gap"
strategy = str(
((db_row.get("receipt_payload") or {}).get("reaction_last_match_strategy") or "")
(
(db_row.get("receipt_payload") or {}).get(
"reaction_last_match_strategy"
)
or ""
)
).strip()
if strategy == "nearest_ts_window":
cause = "ambiguous_reaction_target"

View File

@@ -1,6 +1,7 @@
from django import forms
from django.contrib.auth.forms import UserCreationForm
from django.forms import ModelForm
from mixins.restrictions import RestrictedFormMixin
from .models import (

View File

@@ -8,7 +8,6 @@ from asgiref.sync import sync_to_async
from core.models import GatewayCommandEvent
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
GatewayEmit = Callable[[str], None]
GatewayHandler = Callable[["GatewayCommandContext", GatewayEmit], Awaitable[bool]]
GatewayMatcher = Callable[[str], bool]
@@ -103,7 +102,10 @@ async def dispatch_gateway_command(
emit(message)
event.status = "blocked"
event.error = f"{decision.code}:{decision.reason}"
event.response_meta = {"policy_code": decision.code, "policy_reason": decision.reason}
event.response_meta = {
"policy_code": decision.code,
"policy_reason": decision.reason,
}
await sync_to_async(event.save)(
update_fields=["status", "error", "response_meta", "updated_at"]
)
@@ -129,5 +131,7 @@ async def dispatch_gateway_command(
event.status = "ok" if handled else "ignored"
event.response_meta = {"responses": responses}
await sync_to_async(event.save)(update_fields=["status", "response_meta", "updated_at"])
await sync_to_async(event.save)(
update_fields=["status", "response_meta", "updated_at"]
)
return bool(handled)

View File

@@ -4,7 +4,7 @@ from typing import Iterable
from django.core.management.base import BaseCommand
from core.models import Message, User
from core.models import Message
from core.presence import AvailabilitySignal, record_inferred_signal
from core.presence.inference import now_ms
@@ -19,7 +19,9 @@ class Command(BaseCommand):
parser.add_argument("--user-id", default="")
parser.add_argument("--dry-run", action="store_true", default=False)
def _iter_messages(self, *, days: int, limit: int, service: str, user_id: str) -> Iterable[Message]:
def _iter_messages(
self, *, days: int, limit: int, service: str, user_id: str
) -> Iterable[Message]:
cutoff_ts = now_ms() - (max(1, int(days)) * 24 * 60 * 60 * 1000)
qs = Message.objects.filter(ts__gte=cutoff_ts).select_related(
"user", "session", "session__identifier", "session__identifier__person"
@@ -40,7 +42,9 @@ class Command(BaseCommand):
created = 0
scanned = 0
for msg in self._iter_messages(days=days, limit=limit, service=service_filter, user_id=user_filter):
for msg in self._iter_messages(
days=days, limit=limit, service=service_filter, user_id=user_filter
):
scanned += 1
identifier = getattr(getattr(msg, "session", None), "identifier", None)
person = getattr(identifier, "person", None)
@@ -48,12 +52,18 @@ class Command(BaseCommand):
if not identifier or not person or not user:
continue
service = str(getattr(msg, "source_service", "") or identifier.service or "").strip().lower()
service = (
str(getattr(msg, "source_service", "") or identifier.service or "")
.strip()
.lower()
)
if not service:
continue
base_ts = int(getattr(msg, "ts", 0) or 0)
message_author = str(getattr(msg, "custom_author", "") or "").strip().upper()
message_author = (
str(getattr(msg, "custom_author", "") or "").strip().upper()
)
outgoing = message_author in {"USER", "BOT"}
candidates = []
@@ -84,7 +94,9 @@ class Command(BaseCommand):
"origin": "backfill_contact_availability",
"message_id": str(msg.id),
"inferred_from": "read_receipt",
"read_by": str(getattr(msg, "read_by_identifier", "") or ""),
"read_by": str(
getattr(msg, "read_by_identifier", "") or ""
),
},
}
)

View File

@@ -7,7 +7,12 @@ from asgiref.sync import async_to_sync
from django.core.management.base import BaseCommand
from core.clients.transport import send_message_raw
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProviderConfig
from core.models import (
CodexPermissionRequest,
CodexRun,
ExternalSyncEvent,
TaskProviderConfig,
)
from core.tasks.providers import get_provider
from core.util import logs
@@ -15,7 +20,9 @@ log = logs.get_logger("codex_worker")
class Command(BaseCommand):
help = "Process queued external sync events for worker-backed providers (codex_cli)."
help = (
"Process queued external sync events for worker-backed providers (codex_cli)."
)
def add_arguments(self, parser):
parser.add_argument("--once", action="store_true", default=False)
@@ -73,7 +80,9 @@ class Command(BaseCommand):
payload = dict(event.payload or {})
action = str(payload.get("action") or "append_update").strip().lower()
provider_payload = dict(payload.get("provider_payload") or payload)
run_id = str(provider_payload.get("codex_run_id") or payload.get("codex_run_id") or "").strip()
run_id = str(
provider_payload.get("codex_run_id") or payload.get("codex_run_id") or ""
).strip()
codex_run = None
if run_id:
codex_run = CodexRun.objects.filter(id=run_id, user=event.user).first()
@@ -104,9 +113,13 @@ class Command(BaseCommand):
result_payload = dict(result.payload or {})
requires_approval = bool(result_payload.get("requires_approval"))
if requires_approval:
approval_key = str(result_payload.get("approval_key") or uuid.uuid4().hex[:12]).strip()
approval_key = str(
result_payload.get("approval_key") or uuid.uuid4().hex[:12]
).strip()
permission_request = dict(result_payload.get("permission_request") or {})
summary = str(result_payload.get("summary") or permission_request.get("summary") or "").strip()
summary = str(
result_payload.get("summary") or permission_request.get("summary") or ""
).strip()
requested_permissions = permission_request.get("requested_permissions")
if not isinstance(requested_permissions, (list, dict)):
requested_permissions = permission_request or {}
@@ -121,28 +134,42 @@ class Command(BaseCommand):
codex_run.status = "waiting_approval"
codex_run.result_payload = dict(result_payload)
codex_run.error = ""
codex_run.save(update_fields=["status", "result_payload", "error", "updated_at"])
codex_run.save(
update_fields=["status", "result_payload", "error", "updated_at"]
)
CodexPermissionRequest.objects.update_or_create(
approval_key=approval_key,
defaults={
"user": event.user,
"codex_run": codex_run if codex_run is not None else CodexRun.objects.create(
"codex_run": (
codex_run
if codex_run is not None
else CodexRun.objects.create(
user=event.user,
task=event.task,
derived_task_event=event.task_event,
source_service=str(provider_payload.get("source_service") or ""),
source_channel=str(provider_payload.get("source_channel") or ""),
external_chat_id=str(provider_payload.get("external_chat_id") or ""),
source_service=str(
provider_payload.get("source_service") or ""
),
source_channel=str(
provider_payload.get("source_channel") or ""
),
external_chat_id=str(
provider_payload.get("external_chat_id") or ""
),
status="waiting_approval",
request_payload=dict(payload or {}),
result_payload=dict(result_payload),
error="",
)
),
"external_sync_event": event,
"summary": summary,
"requested_permissions": requested_permissions if isinstance(requested_permissions, dict) else {
"items": list(requested_permissions or [])
},
"requested_permissions": (
requested_permissions
if isinstance(requested_permissions, dict)
else {"items": list(requested_permissions or [])}
),
"resume_payload": dict(resume_payload or {}),
"status": "pending",
"resolved_at": None,
@@ -150,9 +177,17 @@ class Command(BaseCommand):
"resolution_note": "",
},
)
approver_service = str((cfg.settings or {}).get("approver_service") or "").strip().lower()
approver_identifier = str((cfg.settings or {}).get("approver_identifier") or "").strip()
requested_text = result_payload.get("permission_request") or result_payload.get("requested_permissions") or {}
approver_service = (
str((cfg.settings or {}).get("approver_service") or "").strip().lower()
)
approver_identifier = str(
(cfg.settings or {}).get("approver_identifier") or ""
).strip()
requested_text = (
result_payload.get("permission_request")
or result_payload.get("requested_permissions")
or {}
)
if approver_service and approver_identifier:
try:
async_to_sync(send_message_raw)(
@@ -168,10 +203,17 @@ class Command(BaseCommand):
metadata={"origin_tag": f"codex-approval:{approval_key}"},
)
except Exception:
log.exception("failed to notify approver channel for approval_key=%s", approval_key)
log.exception(
"failed to notify approver channel for approval_key=%s",
approval_key,
)
else:
source_service = str(provider_payload.get("source_service") or "").strip().lower()
source_channel = str(provider_payload.get("source_channel") or "").strip()
source_service = (
str(provider_payload.get("source_service") or "").strip().lower()
)
source_channel = str(
provider_payload.get("source_channel") or ""
).strip()
if source_service and source_channel:
try:
async_to_sync(send_message_raw)(
@@ -185,7 +227,9 @@ class Command(BaseCommand):
metadata={"origin_tag": "codex-approval-missing-target"},
)
except Exception:
log.exception("failed to notify source channel for missing approver target")
log.exception(
"failed to notify source channel for missing approver target"
)
return
event.status = "ok" if result.ok else "failed"
@@ -201,18 +245,24 @@ class Command(BaseCommand):
approval_key = str(provider_payload.get("approval_key") or "").strip()
if mode == "approval_response" and approval_key:
req = (
CodexPermissionRequest.objects.select_related("external_sync_event", "codex_run")
CodexPermissionRequest.objects.select_related(
"external_sync_event", "codex_run"
)
.filter(user=event.user, approval_key=approval_key)
.first()
)
if req and req.external_sync_event_id:
if result.ok:
ExternalSyncEvent.objects.filter(id=req.external_sync_event_id).update(
ExternalSyncEvent.objects.filter(
id=req.external_sync_event_id
).update(
status="ok",
error="",
)
elif str(event.error or "").strip() == "approval_denied":
ExternalSyncEvent.objects.filter(id=req.external_sync_event_id).update(
ExternalSyncEvent.objects.filter(
id=req.external_sync_event_id
).update(
status="failed",
error="approval_denied",
)
@@ -220,9 +270,16 @@ class Command(BaseCommand):
codex_run.status = "ok" if result.ok else "failed"
codex_run.error = str(result.error or "")
codex_run.result_payload = result_payload
codex_run.save(update_fields=["status", "error", "result_payload", "updated_at"])
codex_run.save(
update_fields=["status", "error", "result_payload", "updated_at"]
)
if result.ok and result.external_key and event.task_id and not str(event.task.external_key or "").strip():
if (
result.ok
and result.external_key
and event.task_id
and not str(event.task.external_key or "").strip()
):
event.task.external_key = str(result.external_key)
event.task.save(update_fields=["external_key"])
@@ -250,7 +307,11 @@ class Command(BaseCommand):
continue
for row_id in claimed_ids:
event = ExternalSyncEvent.objects.filter(id=row_id).select_related("task", "user").first()
event = (
ExternalSyncEvent.objects.filter(id=row_id)
.select_related("task", "user")
.first()
)
if event is None:
continue
try:

View File

@@ -85,7 +85,9 @@ class Command(BaseCommand):
compared = shadow_compare_session(session, detail_limit=detail_limit)
aggregate["sessions_scanned"] += 1
aggregate["db_message_count"] += int(compared.get("db_message_count") or 0)
aggregate["projected_message_count"] += int(compared.get("projected_message_count") or 0)
aggregate["projected_message_count"] += int(
compared.get("projected_message_count") or 0
)
aggregate["mismatch_total"] += int(compared.get("mismatch_total") or 0)
for key in aggregate["counters"].keys():
aggregate["counters"][key] += int(

View File

@@ -1,14 +1,11 @@
from __future__ import annotations
from collections import defaultdict
from django.core.management.base import BaseCommand
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Message
from core.presence import AvailabilitySignal, record_native_signal
from core.presence.inference import now_ms
_SOURCE_ORDER = {
"message_in": 10,
"message_out": 20,
@@ -51,9 +48,14 @@ class Command(BaseCommand):
if not identifier or not person or not user:
continue
service = str(
getattr(msg, "source_service", "") or getattr(identifier, "service", "")
).strip().lower()
service = (
str(
getattr(msg, "source_service", "")
or getattr(identifier, "service", "")
)
.strip()
.lower()
)
if not service:
continue
@@ -95,12 +97,16 @@ class Command(BaseCommand):
"origin": "recalculate_contact_availability",
"message_id": str(msg.id),
"inferred_from": "read_receipt",
"read_by": str(getattr(msg, "read_by_identifier", "") or ""),
"read_by": str(
getattr(msg, "read_by_identifier", "") or ""
),
},
}
)
reactions = list((getattr(msg, "receipt_payload", {}) or {}).get("reactions") or [])
reactions = list(
(getattr(msg, "receipt_payload", {}) or {}).get("reactions") or []
)
for reaction in reactions:
item = dict(reaction or {})
if bool(item.get("removed")):
@@ -124,7 +130,9 @@ class Command(BaseCommand):
"inferred_from": "reaction",
"emoji": str(item.get("emoji") or ""),
"actor": str(item.get("actor") or ""),
"source_service": str(item.get("source_service") or service),
"source_service": str(
item.get("source_service") or service
),
},
}
)

View File

@@ -67,7 +67,9 @@ def _compute_payload(rows, identifier_values):
pending_out_ts = None
first_ts = int(rows[0]["ts"] or 0)
last_ts = int(rows[-1]["ts"] or 0)
latest_service = str(rows[-1].get("session__identifier__service") or "").strip().lower()
latest_service = (
str(rows[-1].get("session__identifier__service") or "").strip().lower()
)
for row in rows:
ts = int(row.get("ts") or 0)
@@ -162,18 +164,18 @@ def _compute_payload(rows, identifier_values):
payload = {
"source_event_ts": last_ts,
"stability_state": stability_state,
"stability_score": float(stability_score_value)
if stability_score_value is not None
else None,
"stability_score": (
float(stability_score_value) if stability_score_value is not None else None
),
"stability_confidence": round(confidence, 3),
"stability_sample_messages": message_count,
"stability_sample_days": sample_days,
"commitment_inbound_score": float(commitment_in_value)
if commitment_in_value is not None
else None,
"commitment_outbound_score": float(commitment_out_value)
if commitment_out_value is not None
else None,
"commitment_inbound_score": (
float(commitment_in_value) if commitment_in_value is not None else None
),
"commitment_outbound_score": (
float(commitment_out_value) if commitment_out_value is not None else None
),
"commitment_confidence": round(confidence, 3),
"inbound_messages": inbound_count,
"outbound_messages": outbound_count,
@@ -232,15 +234,17 @@ class Command(BaseCommand):
dry_run = bool(options.get("dry_run"))
reset = not bool(options.get("no_reset"))
compact_enabled = not bool(options.get("skip_compact"))
today_start = dj_timezone.now().astimezone(timezone.utc).replace(
today_start = (
dj_timezone.now()
.astimezone(timezone.utc)
.replace(
hour=0,
minute=0,
second=0,
microsecond=0,
)
cutoff_ts = int(
(today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000)
)
cutoff_ts = int((today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000))
people_qs = Person.objects.all()
if user_id:
@@ -256,14 +260,18 @@ class Command(BaseCommand):
compacted_deleted = 0
for person in people:
identifiers_qs = PersonIdentifier.objects.filter(user=person.user, person=person)
identifiers_qs = PersonIdentifier.objects.filter(
user=person.user, person=person
)
if service:
identifiers_qs = identifiers_qs.filter(service=service)
identifiers = list(identifiers_qs)
if not identifiers:
continue
identifier_values = {
str(row.identifier or "").strip() for row in identifiers if row.identifier
str(row.identifier or "").strip()
for row in identifiers
if row.identifier
}
if not identifier_values:
continue
@@ -350,7 +358,9 @@ class Command(BaseCommand):
snapshots_created += 1
if dry_run:
continue
WorkspaceMetricSnapshot.objects.create(conversation=conversation, **payload)
WorkspaceMetricSnapshot.objects.create(
conversation=conversation, **payload
)
existing_signatures.add(signature)
if not latest_payload:
@@ -368,7 +378,9 @@ class Command(BaseCommand):
"updated_at": dj_timezone.now().isoformat(),
}
if not dry_run:
conversation.platform_type = latest_service or conversation.platform_type
conversation.platform_type = (
latest_service or conversation.platform_type
)
conversation.last_event_ts = latest_payload.get("source_event_ts")
conversation.stability_state = str(
latest_payload.get("stability_state")
@@ -416,7 +428,9 @@ class Command(BaseCommand):
)
if compact_enabled:
snapshot_rows = list(
WorkspaceMetricSnapshot.objects.filter(conversation=conversation)
WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
)
.order_by("computed_at", "id")
.values("id", "computed_at", "source_event_ts")
)
@@ -428,7 +442,9 @@ class Command(BaseCommand):
)
if keep_ids:
compacted_deleted += (
WorkspaceMetricSnapshot.objects.filter(conversation=conversation)
WorkspaceMetricSnapshot.objects.filter(
conversation=conversation
)
.exclude(id__in=list(keep_ids))
.delete()[0]
)

View File

@@ -4,4 +4,6 @@ from core.management.commands.codex_worker import Command as LegacyCodexWorkerCo
class Command(LegacyCodexWorkerCommand):
help = "Process queued task-sync events for worker-backed providers (Codex + Claude)."
help = (
"Process queued task-sync events for worker-backed providers (Codex + Claude)."
)

View File

@@ -123,7 +123,9 @@ def _handle_message(message: dict[str, Any]) -> dict[str, Any] | None:
msg_id,
{
"isError": True,
"content": [{"type": "text", "text": json.dumps({"error": str(exc)})}],
"content": [
{"type": "text", "text": json.dumps({"error": str(exc)})}
],
},
)

View File

@@ -216,7 +216,9 @@ def _next_unique_slug(*, user_id: int, requested_slug: str) -> str:
raise ValueError("slug cannot be empty")
candidate = base
idx = 2
while KnowledgeArticle.objects.filter(user_id=int(user_id), slug=candidate).exists():
while KnowledgeArticle.objects.filter(
user_id=int(user_id), slug=candidate
).exists():
suffix = f"-{idx}"
candidate = f"{base[: max(1, 255 - len(suffix))]}{suffix}"
idx += 1
@@ -645,9 +647,7 @@ def tool_wiki_update_article(arguments: dict[str, Any]) -> dict[str, Any]:
)
if status_marker and status == "archived" and article.status != "archived":
if not approve_archive:
raise ValueError(
"approve_archive=true is required to archive an article"
)
raise ValueError("approve_archive=true is required to archive an article")
if title:
article.title = title
@@ -705,7 +705,9 @@ def tool_wiki_list(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_wiki_get(arguments: dict[str, Any]) -> dict[str, Any]:
article = _get_article_for_user(arguments)
include_revisions = bool(arguments.get("include_revisions"))
revision_limit = _safe_limit(arguments.get("revision_limit"), default=20, low=1, high=200)
revision_limit = _safe_limit(
arguments.get("revision_limit"), default=20, low=1, high=200
)
payload = {"article": _article_payload(article)}
if include_revisions:
revisions = article.revisions.order_by("-revision")[:revision_limit]
@@ -714,7 +716,9 @@ def tool_wiki_get(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_project_get_guidelines(arguments: dict[str, Any]) -> dict[str, Any]:
max_chars = _safe_limit(arguments.get("max_chars"), default=16000, low=500, high=50000)
max_chars = _safe_limit(
arguments.get("max_chars"), default=16000, low=500, high=50000
)
base = Path(settings.BASE_DIR).resolve()
file_names = ["AGENTS.md", "LLM_CODING_STANDARDS.md", "INSTALL.md", "README.md"]
payload = []
@@ -734,7 +738,9 @@ def tool_project_get_guidelines(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_project_get_layout(arguments: dict[str, Any]) -> dict[str, Any]:
max_entries = _safe_limit(arguments.get("max_entries"), default=300, low=50, high=4000)
max_entries = _safe_limit(
arguments.get("max_entries"), default=300, low=50, high=4000
)
base = Path(settings.BASE_DIR).resolve()
roots = ["app", "core", "scripts", "utilities", "artifacts"]
items: list[str] = []
@@ -754,7 +760,9 @@ def tool_project_get_layout(arguments: dict[str, Any]) -> dict[str, Any]:
def tool_project_get_runbook(arguments: dict[str, Any]) -> dict[str, Any]:
max_chars = _safe_limit(arguments.get("max_chars"), default=16000, low=500, high=50000)
max_chars = _safe_limit(
arguments.get("max_chars"), default=16000, low=500, high=50000
)
base = Path(settings.BASE_DIR).resolve()
file_names = [
"INSTALL.md",
@@ -792,7 +800,11 @@ def tool_docs_append_run_note(arguments: dict[str, Any]) -> dict[str, Any]:
path = Path("/tmp/gia-mcp-run-notes.md")
else:
candidate = Path(raw_path)
path = candidate.resolve() if candidate.is_absolute() else (base / candidate).resolve()
path = (
candidate.resolve()
if candidate.is_absolute()
else (base / candidate).resolve()
)
allowed_roots = [base, Path("/tmp").resolve()]
if not any(str(path).startswith(str(root)) for root in allowed_roots):
raise ValueError("path must be within project root or /tmp")
@@ -812,7 +824,11 @@ def tool_docs_append_run_note(arguments: dict[str, Any]) -> dict[str, Any]:
TOOL_DEFS: dict[str, dict[str, Any]] = {
"manticore.status": {
"description": "Report configured memory backend status (django or manticore).",
"inputSchema": {"type": "object", "properties": {}, "additionalProperties": False},
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": False,
},
"handler": tool_manticore_status,
},
"manticore.query": {

View File

@@ -1,4 +1,4 @@
from .search_backend import get_memory_search_backend
from .retrieval import retrieve_memories_for_prompt
from .search_backend import get_memory_search_backend
__all__ = ["get_memory_search_backend", "retrieve_memories_for_prompt"]

View File

@@ -224,7 +224,9 @@ def create_memory_change_request(
person_id=person_id or (str(memory.person_id or "") if memory else "") or None,
action=normalized_action,
status="pending",
proposed_memory_kind=str(memory_kind or (memory.memory_kind if memory else "")).strip(),
proposed_memory_kind=str(
memory_kind or (memory.memory_kind if memory else "")
).strip(),
proposed_content=dict(content or {}),
proposed_confidence_score=(
float(confidence_score)
@@ -335,7 +337,9 @@ def review_memory_change_request(
@transaction.atomic
def run_memory_hygiene(*, user_id: int | None = None, dry_run: bool = False) -> dict[str, int]:
def run_memory_hygiene(
*, user_id: int | None = None, dry_run: bool = False
) -> dict[str, int]:
now = timezone.now()
queryset = MemoryItem.objects.filter(status="active")
if user_id is not None:
@@ -357,7 +361,9 @@ def run_memory_hygiene(*, user_id: int | None = None, dry_run: bool = False) ->
for item in queryset.select_related("conversation", "person"):
content = item.content or {}
field = str(content.get("field") or content.get("key") or "").strip().lower()
text = _clean_value(str(content.get("text") or content.get("value") or "")).lower()
text = _clean_value(
str(content.get("text") or content.get("value") or "")
).lower()
if not field or not text:
continue
scope = (

View File

@@ -59,7 +59,11 @@ def retrieve_memories_for_prompt(
limit=safe_limit,
include_statuses=statuses,
)
ids = [str(hit.memory_id or "").strip() for hit in hits if str(hit.memory_id or "").strip()]
ids = [
str(hit.memory_id or "").strip()
for hit in hits
if str(hit.memory_id or "").strip()
]
scoped = _base_queryset(
user_id=int(user_id),
person_id=person_id,
@@ -82,11 +86,17 @@ def retrieve_memories_for_prompt(
"content": item.content or {},
"provenance": item.provenance or {},
"confidence_score": float(item.confidence_score or 0.0),
"expires_at": item.expires_at.isoformat() if item.expires_at else "",
"last_verified_at": (
item.last_verified_at.isoformat() if item.last_verified_at else ""
"expires_at": (
item.expires_at.isoformat() if item.expires_at else ""
),
"last_verified_at": (
item.last_verified_at.isoformat()
if item.last_verified_at
else ""
),
"updated_at": (
item.updated_at.isoformat() if item.updated_at else ""
),
"updated_at": item.updated_at.isoformat() if item.updated_at else "",
"search_score": float(hit.score or 0.0),
"search_summary": str(hit.summary or ""),
}

View File

@@ -1,7 +1,6 @@
from __future__ import annotations
import hashlib
import json
import time
from dataclasses import dataclass
from typing import Any
@@ -144,9 +143,10 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
self.base_url = str(
getattr(settings, "MANTICORE_HTTP_URL", "http://localhost:9308")
).rstrip("/")
self.table = str(
getattr(settings, "MANTICORE_MEMORY_TABLE", "gia_memory_items")
).strip() or "gia_memory_items"
self.table = (
str(getattr(settings, "MANTICORE_MEMORY_TABLE", "gia_memory_items")).strip()
or "gia_memory_items"
)
self.timeout_seconds = int(getattr(settings, "MANTICORE_HTTP_TIMEOUT", 5) or 5)
self._table_cache_key = f"{self.base_url}|{self.table}"
@@ -163,7 +163,9 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
return dict(payload or {})
def ensure_table(self) -> None:
last_ready = float(self._table_ready_cache.get(self._table_cache_key, 0.0) or 0.0)
last_ready = float(
self._table_ready_cache.get(self._table_cache_key, 0.0) or 0.0
)
if (time.time() - last_ready) <= float(self._table_ready_ttl_seconds):
return
self._sql(
@@ -254,7 +256,9 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
try:
values.append(self._build_upsert_values_clause(item))
except Exception as exc:
log.warning("memory-search upsert build failed id=%s err=%s", item.id, exc)
log.warning(
"memory-search upsert build failed id=%s err=%s", item.id, exc
)
continue
if len(values) >= batch_size:
self._sql(
@@ -290,7 +294,11 @@ class ManticoreMemorySearchBackend(BaseMemorySearchBackend):
where_parts = [f"user_id={int(user_id)}", f"MATCH('{self._escape(needle)}')"]
if conversation_id:
where_parts.append(f"conversation_id='{self._escape(conversation_id)}'")
statuses = [str(item or "").strip() for item in include_statuses if str(item or "").strip()]
statuses = [
str(item or "").strip()
for item in include_statuses
if str(item or "").strip()
]
if statuses:
in_clause = ",".join(f"'{self._escape(item)}'" for item in statuses)
where_parts.append(f"status IN ({in_clause})")

View File

@@ -1,12 +1,13 @@
from asgiref.sync import sync_to_async
from django.conf import settings
import time
import uuid
from asgiref.sync import sync_to_async
from django.conf import settings
from core.events.ledger import append_event
from core.messaging.utils import messages_to_string
from core.observability.tracing import ensure_trace_id
from core.models import ChatSession, Message, QueuedMessage
from core.observability.tracing import ensure_trace_id
from core.util import logs
log = logs.get_logger("history")
@@ -272,7 +273,9 @@ async def store_own_message(
trace_id=ensure_trace_id(trace_id, message_meta or {}),
)
except Exception as exc:
log.warning("Event ledger append failed for own message=%s: %s", msg.id, exc)
log.warning(
"Event ledger append failed for own message=%s: %s", msg.id, exc
)
return msg

View File

@@ -335,8 +335,12 @@ def extract_reply_ref(service: str, raw_payload: dict[str, Any]) -> dict[str, st
svc = _clean(service).lower()
payload = _as_dict(raw_payload)
if svc == "xmpp":
reply_id = _clean(payload.get("reply_source_message_id") or payload.get("reply_id"))
reply_chat = _clean(payload.get("reply_source_chat_id") or payload.get("reply_chat_id"))
reply_id = _clean(
payload.get("reply_source_message_id") or payload.get("reply_id")
)
reply_chat = _clean(
payload.get("reply_source_chat_id") or payload.get("reply_chat_id")
)
if reply_id:
return {
"reply_source_message_id": reply_id,
@@ -363,7 +367,9 @@ def extract_origin_tag(raw_payload: dict[str, Any] | None) -> str:
return _find_origin_tag(_as_dict(raw_payload))
async def resolve_reply_target(user, session, reply_ref: dict[str, str]) -> Message | None:
async def resolve_reply_target(
user, session, reply_ref: dict[str, str]
) -> Message | None:
if not reply_ref or session is None:
return None
reply_source_message_id = _clean(reply_ref.get("reply_source_message_id"))

View File

@@ -1,7 +1,8 @@
# Generated by Django 5.2.11 on 2026-03-02 11:55
import django.db.models.deletion
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models

View File

@@ -1,6 +1,6 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):

View File

@@ -1,8 +1,8 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):

View File

@@ -1,8 +1,8 @@
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):

View File

@@ -1,8 +1,8 @@
# Generated by Django 4.2.19 on 2026-03-07 00:00
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.2.7 on 2026-03-07 20:12
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0041_useraccessibilitysettings'),
]
operations = [
migrations.CreateModel(
name='UserXmppOmemoTrustedKey',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('jid', models.CharField(blank=True, default='', max_length=255)),
('key_type', models.CharField(choices=[('fingerprint', 'Fingerprint'), ('client_key', 'Client key')], default='fingerprint', max_length=32)),
('key_id', models.CharField(max_length=255)),
('trusted', models.BooleanField(default=False)),
('source', models.CharField(blank=True, default='', max_length=64)),
('last_seen_at', models.DateTimeField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='xmpp_omemo_trusted_keys', to=settings.AUTH_USER_MODEL)),
],
options={
'indexes': [
models.Index(fields=['user', 'trusted', 'updated_at'], name='core_userxomemo_trusted_idx'),
models.Index(fields=['user', 'jid', 'updated_at'], name='core_userxomemo_jid_idx'),
],
'constraints': [
models.UniqueConstraint(fields=('user', 'jid', 'key_type', 'key_id'), name='unique_user_xmpp_omemo_trusted_key'),
],
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.2.7 on 2026-03-07 20:23
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0042_userxmppomemotrustedkey_and_more"),
]
operations = [
migrations.AddField(
model_name="userxmppsecuritysettings",
name="encrypt_contact_messages_with_omemo",
field=models.BooleanField(default=True),
),
]

View File

@@ -20,7 +20,7 @@ SERVICE_CHOICES = (
)
CHANNEL_SERVICE_CHOICES = SERVICE_CHOICES + (("web", "Web"),)
MBTI_CHOICES = (
("INTJ", "INTJ - Architect"),# ;)
("INTJ", "INTJ - Architect"), # ;)
("INTP", "INTP - Logician"),
("ENTJ", "ENTJ - Commander"),
("ENTP", "ENTP - Debater"),
@@ -241,17 +241,13 @@ class PlatformChatLink(models.Model):
raise ValidationError("Person must belong to the same user.")
if self.person_identifier_id:
if self.person_identifier.user_id != self.user_id:
raise ValidationError(
"Person identifier must belong to the same user."
)
raise ValidationError("Person identifier must belong to the same user.")
if self.person_identifier.person_id != self.person_id:
raise ValidationError(
"Person identifier must belong to the selected person."
)
if self.person_identifier.service != self.service:
raise ValidationError(
"Chat links cannot be linked across platforms."
)
raise ValidationError("Chat links cannot be linked across platforms.")
def save(self, *args, **kwargs):
value = str(self.chat_identifier or "").strip()
@@ -1869,9 +1865,7 @@ class PatternArtifactExport(models.Model):
class CommandProfile(models.Model):
WINDOW_SCOPE_CHOICES = (
("conversation", "Conversation"),
)
WINDOW_SCOPE_CHOICES = (("conversation", "Conversation"),)
VISIBILITY_CHOICES = (
("status_in_source", "Status In Source"),
("silent", "Silent"),
@@ -2039,7 +2033,9 @@ class BusinessPlanDocument(models.Model):
class Meta:
indexes = [
models.Index(fields=["user", "status", "updated_at"]),
models.Index(fields=["user", "source_service", "source_channel_identifier"]),
models.Index(
fields=["user", "source_service", "source_channel_identifier"]
),
]
@@ -2243,7 +2239,9 @@ class TranslationEventLog(models.Model):
class AnswerMemory(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="answer_memory")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="answer_memory"
)
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
channel_identifier = models.CharField(max_length=255)
question_fingerprint = models.CharField(max_length=128)
@@ -2261,7 +2259,9 @@ class AnswerMemory(models.Model):
class Meta:
indexes = [
models.Index(fields=["user", "service", "channel_identifier", "created_at"]),
models.Index(
fields=["user", "service", "channel_identifier", "created_at"]
),
models.Index(fields=["user", "question_fingerprint", "created_at"]),
]
@@ -2284,7 +2284,9 @@ class AnswerSuggestionEvent(models.Model):
on_delete=models.CASCADE,
related_name="answer_suggestion_events",
)
status = models.CharField(max_length=32, choices=STATUS_CHOICES, default="suggested")
status = models.CharField(
max_length=32, choices=STATUS_CHOICES, default="suggested"
)
candidate_answer = models.ForeignKey(
AnswerMemory,
on_delete=models.SET_NULL,
@@ -2305,7 +2307,9 @@ class AnswerSuggestionEvent(models.Model):
class TaskProject(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_projects")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="task_projects"
)
name = models.CharField(max_length=255)
external_key = models.CharField(max_length=255, blank=True, default="")
active = models.BooleanField(default=True)
@@ -2349,7 +2353,9 @@ class TaskEpic(models.Model):
class ChatTaskSource(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="chat_task_sources")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="chat_task_sources"
)
service = models.CharField(max_length=255, choices=CHANNEL_SERVICE_CHOICES)
channel_identifier = models.CharField(max_length=255)
project = models.ForeignKey(
@@ -2378,7 +2384,9 @@ class ChatTaskSource(models.Model):
class DerivedTask(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="derived_tasks")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="derived_tasks"
)
project = models.ForeignKey(
TaskProject,
on_delete=models.CASCADE,
@@ -2574,7 +2582,9 @@ class ExternalSyncEvent(models.Model):
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="external_sync_events")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="external_sync_events"
)
task = models.ForeignKey(
DerivedTask,
on_delete=models.SET_NULL,
@@ -2606,7 +2616,9 @@ class ExternalSyncEvent(models.Model):
class TaskProviderConfig(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_provider_configs")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="task_provider_configs"
)
provider = models.CharField(max_length=64, default="mock")
enabled = models.BooleanField(default=False)
settings = models.JSONField(default=dict, blank=True)
@@ -2684,7 +2696,9 @@ class CodexRun(models.Model):
class Meta:
indexes = [
models.Index(fields=["user", "status", "updated_at"]),
models.Index(fields=["user", "source_service", "source_channel", "created_at"]),
models.Index(
fields=["user", "source_service", "source_channel", "created_at"]
),
]
@@ -2697,7 +2711,9 @@ class CodexPermissionRequest(models.Model):
)
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="codex_permission_requests")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="codex_permission_requests"
)
codex_run = models.ForeignKey(
CodexRun,
on_delete=models.CASCADE,
@@ -2910,7 +2926,49 @@ class UserXmppOmemoState(models.Model):
class Meta:
indexes = [
models.Index(fields=["status", "updated_at"], name="core_userxm_status_133ead_idx"),
models.Index(
fields=["status", "updated_at"], name="core_userxm_status_133ead_idx"
),
]
class UserXmppOmemoTrustedKey(models.Model):
KEY_TYPE_CHOICES = (
("fingerprint", "Fingerprint"),
("client_key", "Client key"),
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="xmpp_omemo_trusted_keys",
)
jid = models.CharField(max_length=255, blank=True, default="")
key_type = models.CharField(
max_length=32, choices=KEY_TYPE_CHOICES, default="fingerprint"
)
key_id = models.CharField(max_length=255)
trusted = models.BooleanField(default=False)
source = models.CharField(max_length=64, blank=True, default="")
last_seen_at = models.DateTimeField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["user", "jid", "key_type", "key_id"],
name="unique_user_xmpp_omemo_trusted_key",
),
]
indexes = [
models.Index(
fields=["user", "trusted", "updated_at"],
name="core_userxomemo_trusted_idx",
),
models.Index(
fields=["user", "jid", "updated_at"], name="core_userxomemo_jid_idx"
),
]
@@ -2921,6 +2979,7 @@ class UserXmppSecuritySettings(models.Model):
related_name="xmpp_security_settings",
)
require_omemo = models.BooleanField(default=False)
encrypt_contact_messages_with_omemo = models.BooleanField(default=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -2938,7 +2997,9 @@ class UserAccessibilitySettings(models.Model):
class TaskCompletionPattern(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="task_completion_patterns")
user = models.ForeignKey(
User, on_delete=models.CASCADE, related_name="task_completion_patterns"
)
phrase = models.CharField(max_length=64)
enabled = models.BooleanField(default=True)
position = models.PositiveIntegerField(default=0)

View File

@@ -4,22 +4,22 @@ import re
from asgiref.sync import sync_to_async
from django.conf import settings
from core.assist.engine import process_inbound_assist
from core.clients import transport
from core.events import event_ledger_status
from core.clients.instagram import InstagramClient
from core.clients.signal import SignalClient
from core.clients.whatsapp import WhatsAppClient
from core.clients.xmpp import XMPPClient
from core.assist.engine import process_inbound_assist
from core.commands.base import CommandContext
from core.commands.engine import process_inbound_message
from core.events import event_ledger_status
from core.messaging import history
from core.models import PersonIdentifier
from core.observability.tracing import ensure_trace_id
from core.presence import AvailabilitySignal, record_native_signal
from core.realtime.typing_state import set_person_typing_state
from core.translation.engine import process_inbound_translation
from core.util import logs
from core.observability.tracing import ensure_trace_id
class UnifiedRouter(object):
@@ -119,7 +119,9 @@ class UnifiedRouter(object):
return
identifiers = await self._resolve_identifier_objects(protocol, identifier)
if identifiers:
outgoing = str(getattr(local_message, "custom_author", "") or "").strip().upper() in {
outgoing = str(
getattr(local_message, "custom_author", "") or ""
).strip().upper() in {
"USER",
"BOT",
}
@@ -268,7 +270,9 @@ class UnifiedRouter(object):
ts=int(read_ts or 0),
payload={
"origin": "router.message_read",
"message_timestamps": [int(v) for v in list(timestamps or []) if str(v).isdigit()],
"message_timestamps": [
int(v) for v in list(timestamps or []) if str(v).isdigit()
],
"read_by": str(read_by or row.identifier),
},
)

View File

@@ -12,9 +12,15 @@ from core.models import (
PersonIdentifier,
User,
)
from .inference import fade_confidence, now_ms, should_fade
POSITIVE_SOURCE_KINDS = {"native_presence", "read_receipt", "typing_start", "message_in"}
POSITIVE_SOURCE_KINDS = {
"native_presence",
"read_receipt",
"typing_start",
"message_in",
}
@dataclass(slots=True)
@@ -99,7 +105,8 @@ def record_native_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent
person_identifier=signal.person_identifier,
service=str(signal.service or "").strip().lower() or "signal",
source_kind=str(signal.source_kind or "").strip() or "native_presence",
availability_state=str(signal.availability_state or "unknown").strip() or "unknown",
availability_state=str(signal.availability_state or "unknown").strip()
or "unknown",
confidence=float(signal.confidence or 0.0),
ts=_normalize_ts(signal.ts),
payload=dict(signal.payload or {}),
@@ -109,7 +116,9 @@ def record_native_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent
return event
def record_inferred_signal(signal: AvailabilitySignal) -> ContactAvailabilityEvent | None:
def record_inferred_signal(
signal: AvailabilitySignal,
) -> ContactAvailabilityEvent | None:
settings_row = get_settings(signal.user)
if not settings_row.enabled or not settings_row.inference_enabled:
return None
@@ -151,7 +160,9 @@ def ensure_fading_state(
return None
if latest.source_kind not in POSITIVE_SOURCE_KINDS:
return None
if not should_fade(int(latest.ts or 0), current_ts, settings_row.fade_threshold_seconds):
if not should_fade(
int(latest.ts or 0), current_ts, settings_row.fade_threshold_seconds
):
return None
elapsed = max(0, current_ts - int(latest.ts or 0))

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from django.db.models import Q
from core.models import ContactAvailabilityEvent, ContactAvailabilitySpan, Person, User
from .engine import ensure_fading_state
from .inference import now_ms
@@ -19,9 +20,7 @@ def spans_for_range(
qs = ContactAvailabilitySpan.objects.filter(
user=user,
person=person,
).filter(
Q(start_ts__lte=end_ts) & Q(end_ts__gte=start_ts)
)
).filter(Q(start_ts__lte=end_ts) & Q(end_ts__gte=start_ts))
if service:
qs = qs.filter(service=str(service).strip().lower())

View File

@@ -1,2 +1 @@
"""Security helpers shared across transport adapters."""

View File

@@ -101,7 +101,9 @@ def validate_attachment_metadata(
raise ValueError(f"blocked_mime_type:{normalized_type}")
allow_unmatched = bool(getattr(settings, "ATTACHMENT_ALLOW_UNKNOWN_MIME", False))
if not any(fnmatch(normalized_type, pattern) for pattern in _allowed_mime_patterns()):
if not any(
fnmatch(normalized_type, pattern) for pattern in _allowed_mime_patterns()
):
if not allow_unmatched:
raise ValueError(f"unsupported_mime_type:{normalized_type}")

View File

@@ -68,15 +68,13 @@ def _omemo_facts(ctx: CommandSecurityContext) -> tuple[str, str]:
message_meta = dict(ctx.message_meta or {})
payload = dict(ctx.payload or {})
xmpp_meta = dict(message_meta.get("xmpp") or {})
status = str(
xmpp_meta.get("omemo_status")
or payload.get("omemo_status")
or ""
).strip().lower()
status = (
str(xmpp_meta.get("omemo_status") or payload.get("omemo_status") or "")
.strip()
.lower()
)
client_key = str(
xmpp_meta.get("omemo_client_key")
or payload.get("omemo_client_key")
or ""
xmpp_meta.get("omemo_client_key") or payload.get("omemo_client_key") or ""
).strip()
return status, client_key
@@ -160,7 +158,8 @@ def evaluate_command_policy(
service = _normalize_service(context.service)
channel = _normalize_channel(context.channel_identifier)
allowed_services = [
item.lower() for item in _normalize_list(getattr(policy, "allowed_services", []))
item.lower()
for item in _normalize_list(getattr(policy, "allowed_services", []))
]
global_allowed_services = [
item.lower()

View File

@@ -83,7 +83,9 @@ def ensure_default_source_for_chat(
message=None,
):
service_key = str(service or "").strip().lower()
normalized_identifier = normalize_channel_identifier(service_key, channel_identifier)
normalized_identifier = normalize_channel_identifier(
service_key, channel_identifier
)
variants = channel_variants(service_key, normalized_identifier)
if not service_key or not variants:
return None

View File

@@ -72,9 +72,13 @@ def queue_codex_event_with_pre_approval(
},
)
cfg = TaskProviderConfig.objects.filter(user=user, provider=provider, enabled=True).first()
cfg = TaskProviderConfig.objects.filter(
user=user, provider=provider, enabled=True
).first()
settings_payload = dict(getattr(cfg, "settings", {}) or {})
approver_service = str(settings_payload.get("approver_service") or "").strip().lower()
approver_service = (
str(settings_payload.get("approver_service") or "").strip().lower()
)
approver_identifier = str(settings_payload.get("approver_identifier") or "").strip()
if approver_service and approver_identifier:
try:

View File

@@ -57,7 +57,9 @@ def resolve_external_chat_id(*, user, provider: str, service: str, channel: str)
provider=provider,
enabled=True,
)
.filter(Q(person_identifier=person_identifier) | Q(person=person_identifier.person))
.filter(
Q(person_identifier=person_identifier) | Q(person=person_identifier.person)
)
.order_by("-updated_at", "-id")
.first()
)

View File

@@ -22,16 +22,23 @@ from core.models import (
TaskEpic,
TaskProviderConfig,
)
from core.tasks.chat_defaults import ensure_default_source_for_chat, resolve_message_scope
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.providers import get_provider
from core.tasks.codex_support import resolve_external_chat_id
from core.security.command_policy import CommandSecurityContext, evaluate_command_policy
from core.tasks.chat_defaults import (
ensure_default_source_for_chat,
resolve_message_scope,
)
from core.tasks.codex_approval import queue_codex_event_with_pre_approval
from core.tasks.codex_support import resolve_external_chat_id
from core.tasks.providers import get_provider
_TASK_HINT_RE = re.compile(r"\b(todo|task|action|need to|please)\b", re.IGNORECASE)
_COMPLETION_RE = re.compile(r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE)
_COMPLETION_RE = re.compile(
r"\b(done|completed|fixed)\s*#([A-Za-z0-9_-]+)\b", re.IGNORECASE
)
_BALANCED_HINT_RE = re.compile(r"\b(todo|task|action item|action)\b", re.IGNORECASE)
_BROAD_HINT_RE = re.compile(r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE)
_BROAD_HINT_RE = re.compile(
r"\b(todo|task|action|need to|please|reminder)\b", re.IGNORECASE
)
_PREFIX_HEAD_TRIM = " \t\r\n`'\"([{<*#-—_>.,:;!/?\\|"
_LIST_TASKS_RE = re.compile(
r"^\s*(?:\.l(?:\s+list(?:\s+tasks?)?)?|\.list(?:\s+tasks?)?)\s*$",
@@ -151,15 +158,23 @@ async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
lookup_service = str(message.source_service or "").strip().lower()
variants = _channel_variants(lookup_service, message.source_chat_id or "")
session_identifier = getattr(getattr(message, "session", None), "identifier", None)
canonical_service = str(getattr(session_identifier, "service", "") or "").strip().lower()
canonical_identifier = str(getattr(session_identifier, "identifier", "") or "").strip()
canonical_service = (
str(getattr(session_identifier, "service", "") or "").strip().lower()
)
canonical_identifier = str(
getattr(session_identifier, "identifier", "") or ""
).strip()
if lookup_service == "web" and canonical_service and canonical_service != "web":
lookup_service = canonical_service
variants = _channel_variants(lookup_service, message.source_chat_id or "")
for expanded in _channel_variants(lookup_service, canonical_identifier):
if expanded and expanded not in variants:
variants.append(expanded)
elif canonical_service and canonical_identifier and canonical_service == lookup_service:
elif (
canonical_service
and canonical_identifier
and canonical_service == lookup_service
):
for expanded in _channel_variants(canonical_service, canonical_identifier):
if expanded and expanded not in variants:
variants.append(expanded)
@@ -170,10 +185,14 @@ async def _resolve_source_mappings(message: Message) -> list[ChatTaskSource]:
if not signal_value:
continue
companions += await sync_to_async(list)(
Chat.objects.filter(source_uuid=signal_value).values_list("source_number", flat=True)
Chat.objects.filter(source_uuid=signal_value).values_list(
"source_number", flat=True
)
)
companions += await sync_to_async(list)(
Chat.objects.filter(source_number=signal_value).values_list("source_uuid", flat=True)
Chat.objects.filter(source_number=signal_value).values_list(
"source_uuid", flat=True
)
)
for candidate in companions:
for expanded in _channel_variants("signal", str(candidate or "").strip()):
@@ -271,7 +290,8 @@ def _normalize_flags(raw: dict | None) -> dict:
row = dict(raw or {})
return {
"derive_enabled": _to_bool(row.get("derive_enabled"), True),
"match_mode": str(row.get("match_mode") or "balanced").strip().lower() or "balanced",
"match_mode": str(row.get("match_mode") or "balanced").strip().lower()
or "balanced",
"require_prefix": _to_bool(row.get("require_prefix"), False),
"allowed_prefixes": _parse_prefixes(row.get("allowed_prefixes")),
"completion_enabled": _to_bool(row.get("completion_enabled"), True),
@@ -287,7 +307,9 @@ def _normalize_partial_flags(raw: dict | None) -> dict:
if "derive_enabled" in row:
out["derive_enabled"] = _to_bool(row.get("derive_enabled"), True)
if "match_mode" in row:
out["match_mode"] = str(row.get("match_mode") or "balanced").strip().lower() or "balanced"
out["match_mode"] = (
str(row.get("match_mode") or "balanced").strip().lower() or "balanced"
)
if "require_prefix" in row:
out["require_prefix"] = _to_bool(row.get("require_prefix"), False)
if "allowed_prefixes" in row:
@@ -304,7 +326,9 @@ def _normalize_partial_flags(raw: dict | None) -> dict:
def _effective_flags(source: ChatTaskSource) -> dict:
project_flags = _normalize_flags(getattr(getattr(source, "project", None), "settings", {}) or {})
project_flags = _normalize_flags(
getattr(getattr(source, "project", None), "settings", {}) or {}
)
source_flags = _normalize_partial_flags(getattr(source, "settings", {}) or {})
merged = dict(project_flags)
merged.update(source_flags)
@@ -360,7 +384,10 @@ async def _derive_title(message: Message) -> str:
{"role": "user", "content": text[:2000]},
]
try:
title = str(await ai_runner.run_prompt(prompt, ai_obj, operation="task_derive_title") or "").strip()
title = str(
await ai_runner.run_prompt(prompt, ai_obj, operation="task_derive_title")
or ""
).strip()
except Exception:
title = ""
return (title or text)[:255]
@@ -376,9 +403,13 @@ async def _derive_title_with_flags(message: Message, flags: dict) -> str:
return (cleaned or title or "Untitled task")[:255]
async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: str) -> None:
async def _emit_sync_event(
task: DerivedTask, event: DerivedTaskEvent, action: str
) -> None:
cfg = await sync_to_async(
lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True).order_by("provider").first()
lambda: TaskProviderConfig.objects.filter(user=task.user, enabled=True)
.order_by("provider")
.first()
)()
provider_name = str(getattr(cfg, "provider", "mock") or "mock")
provider_settings = dict(getattr(cfg, "settings", {}) or {})
@@ -416,7 +447,11 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
"source_channel": str(task.source_channel or ""),
"external_chat_id": external_chat_id,
"origin_message_id": str(getattr(task, "origin_message_id", "") or ""),
"trigger_message_id": str(getattr(event, "source_message_id", "") or getattr(task, "origin_message_id", "") or ""),
"trigger_message_id": str(
getattr(event, "source_message_id", "")
or getattr(task, "origin_message_id", "")
or ""
),
"mode": "default",
"payload": event.payload,
"memory_context": memory_context,
@@ -495,7 +530,9 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
codex_run.status = status
codex_run.result_payload = dict(result.payload or {})
codex_run.error = str(result.error or "")
await sync_to_async(codex_run.save)(update_fields=["status", "result_payload", "error", "updated_at"])
await sync_to_async(codex_run.save)(
update_fields=["status", "result_payload", "error", "updated_at"]
)
if result.ok and result.external_key and not task.external_key:
task.external_key = str(result.external_key)
await sync_to_async(task.save)(update_fields=["external_key"])
@@ -503,15 +540,28 @@ async def _emit_sync_event(task: DerivedTask, event: DerivedTaskEvent, action: s
async def _completion_regex(message: Message) -> re.Pattern:
patterns = await sync_to_async(list)(
TaskCompletionPattern.objects.filter(user=message.user, enabled=True).order_by("position", "created_at")
TaskCompletionPattern.objects.filter(user=message.user, enabled=True).order_by(
"position", "created_at"
)
phrases = [str(row.phrase or "").strip() for row in patterns if str(row.phrase or "").strip()]
)
phrases = [
str(row.phrase or "").strip()
for row in patterns
if str(row.phrase or "").strip()
]
if not phrases:
phrases = ["done", "completed", "fixed"]
return re.compile(r"\\b(?:" + "|".join(re.escape(p) for p in phrases) + r")\\s*#([A-Za-z0-9_-]+)\\b", re.IGNORECASE)
return re.compile(
r"\\b(?:"
+ "|".join(re.escape(p) for p in phrases)
+ r")\\s*#([A-Za-z0-9_-]+)\\b",
re.IGNORECASE,
)
async def _send_scope_message(source: ChatTaskSource, message: Message, text: str) -> None:
async def _send_scope_message(
source: ChatTaskSource, message: Message, text: str
) -> None:
await send_message_raw(
source.service or message.source_service or "web",
source.channel_identifier or message.source_chat_id or "",
@@ -521,7 +571,9 @@ async def _send_scope_message(source: ChatTaskSource, message: Message, text: st
)
async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSource], text: str) -> bool:
async def _handle_scope_task_commands(
message: Message, sources: list[ChatTaskSource], text: str
) -> bool:
if not sources:
return False
body = str(text or "").strip()
@@ -538,7 +590,9 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.order_by("-created_at")[:20]
)
if not open_rows:
await _send_scope_message(source, message, "[task] no open tasks in this chat.")
await _send_scope_message(
source, message, "[task] no open tasks in this chat."
)
return True
lines = ["[task] open tasks:"]
for row in open_rows:
@@ -573,7 +627,9 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.first()
)()
if task is None:
await _send_scope_message(source, message, "[task] nothing to undo in this chat.")
await _send_scope_message(
source, message, "[task] nothing to undo in this chat."
)
return True
ref = str(task.reference_code or "")
title = str(task.title or "")
@@ -596,10 +652,16 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.first()
)()
if task is None:
await _send_scope_message(source, message, f"[task] #{reference} not found.")
await _send_scope_message(
source, message, f"[task] #{reference} not found."
)
return True
due_str = f"\ndue: {task.due_date}" if task.due_date else ""
assignee_str = f"\nassignee: {task.assignee_identifier}" if task.assignee_identifier else ""
assignee_str = (
f"\nassignee: {task.assignee_identifier}"
if task.assignee_identifier
else ""
)
detail = (
f"[task] #{task.reference_code}: {task.title}"
f"\nstatus: {task.status_snapshot}"
@@ -624,7 +686,9 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
.first()
)()
if task is None:
await _send_scope_message(source, message, f"[task] #{reference} not found.")
await _send_scope_message(
source, message, f"[task] #{reference} not found."
)
return True
task.status_snapshot = "completed"
await sync_to_async(task.save)(update_fields=["status_snapshot"])
@@ -633,10 +697,16 @@ async def _handle_scope_task_commands(message: Message, sources: list[ChatTaskSo
event_type="completion_marked",
actor_identifier=str(message.sender_uuid or ""),
source_message=message,
payload={"marker": reference, "command": ".task complete", "via": "chat_command"},
payload={
"marker": reference,
"command": ".task complete",
"via": "chat_command",
},
)
await _emit_sync_event(task, event, "complete")
await _send_scope_message(source, message, f"[task] completed #{task.reference_code}: {task.title}")
await _send_scope_message(
source, message, f"[task] completed #{task.reference_code}: {task.title}"
)
return True
return False
@@ -656,7 +726,9 @@ def _strip_epic_token(text: str) -> str:
return re.sub(r"\s{2,}", " ", cleaned).strip()
async def _handle_epic_create_command(message: Message, sources: list[ChatTaskSource], text: str) -> bool:
async def _handle_epic_create_command(
message: Message, sources: list[ChatTaskSource], text: str
) -> bool:
match = _EPIC_CREATE_RE.match(str(text or ""))
if not match or not sources:
return False
@@ -766,13 +838,21 @@ async def process_inbound_task_intelligence(message: Message) -> None:
if not submit_decision.allowed:
return
completion_allowed = any(bool(_effective_flags(source).get("completion_enabled")) for source in sources)
completion_allowed = any(
bool(_effective_flags(source).get("completion_enabled")) for source in sources
)
completion_rx = await _completion_regex(message) if completion_allowed else None
marker_match = (completion_rx.search(text) if completion_rx else None) or (_COMPLETION_RE.search(text) if completion_allowed else None)
marker_match = (completion_rx.search(text) if completion_rx else None) or (
_COMPLETION_RE.search(text) if completion_allowed else None
)
if marker_match:
ref_code = str(marker_match.group(marker_match.lastindex or 1) or "").strip()
task = await sync_to_async(
lambda: DerivedTask.objects.filter(user=message.user, reference_code=ref_code).order_by("-created_at").first()
lambda: DerivedTask.objects.filter(
user=message.user, reference_code=ref_code
)
.order_by("-created_at")
.first()
)()
if not task:
# parser warning event attached to a newly derived placeholder in mapped project
@@ -848,7 +928,11 @@ async def process_inbound_task_intelligence(message: Message) -> None:
status_snapshot="open",
due_date=parsed_due_date,
assignee_identifier=parsed_assignee,
immutable_payload={"origin_text": text, "task_text": task_text, "flags": flags},
immutable_payload={
"origin_text": text,
"task_text": task_text,
"flags": flags,
},
)
event = await sync_to_async(DerivedTaskEvent.objects.create)(
task=task,

View File

@@ -40,13 +40,21 @@ class ClaudeCLITaskProvider(TaskProvider):
return True
if "unrecognized subcommand 'create'" in text and "usage: claude" in text:
return True
if "unrecognized subcommand 'append_update'" in text and "usage: claude" in text:
if (
"unrecognized subcommand 'append_update'" in text
and "usage: claude" in text
):
return True
if "unrecognized subcommand 'mark_complete'" in text and "usage: claude" in text:
if (
"unrecognized subcommand 'mark_complete'" in text
and "usage: claude" in text
):
return True
return False
def _builtin_stub_result(self, op: str, payload: dict, stderr: str) -> ProviderResult:
def _builtin_stub_result(
self, op: str, payload: dict, stderr: str
) -> ProviderResult:
mode = str(payload.get("mode") or "default").strip().lower()
external_key = (
str(payload.get("external_key") or "").strip()
@@ -117,7 +125,10 @@ class ClaudeCLITaskProvider(TaskProvider):
cwd=workspace if workspace else None,
)
stderr_probe = str(completed.stderr or "").lower()
if completed.returncode != 0 and "unexpected argument '--op'" in stderr_probe:
if (
completed.returncode != 0
and "unexpected argument '--op'" in stderr_probe
):
completed = subprocess.run(
fallback_cmd,
capture_output=True,
@@ -133,7 +144,9 @@ class ClaudeCLITaskProvider(TaskProvider):
payload={"op": op, "timeout_seconds": command_timeout},
)
except Exception as exc:
return ProviderResult(ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op})
return ProviderResult(
ok=False, error=f"claude_cli_exec_error:{exc}", payload={"op": op}
)
stdout = str(completed.stdout or "").strip()
stderr = str(completed.stderr or "").strip()
@@ -172,7 +185,12 @@ class ClaudeCLITaskProvider(TaskProvider):
out_payload.update(parsed)
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
return self._builtin_stub_result(op, dict(payload or {}), stderr)
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
return ProviderResult(
ok=ok,
external_key=ext,
error=("" if ok else stderr[:4000]),
payload=out_payload,
)
def healthcheck(self, config: dict) -> ProviderResult:
command = self._command(config)
@@ -193,7 +211,11 @@ class ClaudeCLITaskProvider(TaskProvider):
"stdout": str(completed.stdout or "").strip()[:1000],
"stderr": str(completed.stderr or "").strip()[:1000],
},
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]),
error=(
""
if completed.returncode == 0
else str(completed.stderr or "").strip()[:1000]
),
)
def create_task(self, config: dict, payload: dict) -> ProviderResult:

View File

@@ -46,7 +46,9 @@ class CodexCLITaskProvider(TaskProvider):
return True
return False
def _builtin_stub_result(self, op: str, payload: dict, stderr: str) -> ProviderResult:
def _builtin_stub_result(
self, op: str, payload: dict, stderr: str
) -> ProviderResult:
mode = str(payload.get("mode") or "default").strip().lower()
external_key = (
str(payload.get("external_key") or "").strip()
@@ -117,7 +119,10 @@ class CodexCLITaskProvider(TaskProvider):
cwd=workspace if workspace else None,
)
stderr_probe = str(completed.stderr or "").lower()
if completed.returncode != 0 and "unexpected argument '--op'" in stderr_probe:
if (
completed.returncode != 0
and "unexpected argument '--op'" in stderr_probe
):
completed = subprocess.run(
fallback_cmd,
capture_output=True,
@@ -133,7 +138,9 @@ class CodexCLITaskProvider(TaskProvider):
payload={"op": op, "timeout_seconds": command_timeout},
)
except Exception as exc:
return ProviderResult(ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op})
return ProviderResult(
ok=False, error=f"codex_cli_exec_error:{exc}", payload={"op": op}
)
stdout = str(completed.stdout or "").strip()
stderr = str(completed.stderr or "").strip()
@@ -172,7 +179,12 @@ class CodexCLITaskProvider(TaskProvider):
out_payload.update(parsed)
if (not ok) and self._is_task_sync_contract_mismatch(stderr):
return self._builtin_stub_result(op, dict(payload or {}), stderr)
return ProviderResult(ok=ok, external_key=ext, error=("" if ok else stderr[:4000]), payload=out_payload)
return ProviderResult(
ok=ok,
external_key=ext,
error=("" if ok else stderr[:4000]),
payload=out_payload,
)
def healthcheck(self, config: dict) -> ProviderResult:
command = self._command(config)
@@ -193,7 +205,11 @@ class CodexCLITaskProvider(TaskProvider):
"stdout": str(completed.stdout or "").strip()[:1000],
"stderr": str(completed.stderr or "").strip()[:1000],
},
error=("" if completed.returncode == 0 else str(completed.stderr or "").strip()[:1000]),
error=(
""
if completed.returncode == 0
else str(completed.stderr or "").strip()[:1000]
),
)
def create_task(self, config: dict, payload: dict) -> ProviderResult:

View File

@@ -12,14 +12,30 @@ class MockTaskProvider(TaskProvider):
return ProviderResult(ok=True, payload={"provider": self.name})
def create_task(self, config: dict, payload: dict) -> ProviderResult:
ext = str(payload.get("external_key") or "") or f"mock-{int(time.time() * 1000)}"
return ProviderResult(ok=True, external_key=ext, payload={"action": "create_task"})
ext = (
str(payload.get("external_key") or "") or f"mock-{int(time.time() * 1000)}"
)
return ProviderResult(
ok=True, external_key=ext, payload={"action": "create_task"}
)
def append_update(self, config: dict, payload: dict) -> ProviderResult:
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "append_update"})
return ProviderResult(
ok=True,
external_key=str(payload.get("external_key") or ""),
payload={"action": "append_update"},
)
def mark_complete(self, config: dict, payload: dict) -> ProviderResult:
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "mark_complete"})
return ProviderResult(
ok=True,
external_key=str(payload.get("external_key") or ""),
payload={"action": "mark_complete"},
)
def link_task(self, config: dict, payload: dict) -> ProviderResult:
return ProviderResult(ok=True, external_key=str(payload.get("external_key") or ""), payload={"action": "link_task"})
return ProviderResult(
ok=True,
external_key=str(payload.get("external_key") or ""),
payload={"action": "link_task"},
)

View File

@@ -342,7 +342,7 @@
hx-trigger="click"
hx-swap="innerHTML">
<span class="icon is-small"><i class="fa-solid fa-paper-plane"></i></span>
<span style="margin-left: 0.35rem;">Message</span>
<span style="margin-left: 0.35rem;">Compose</span>
</a>
<div class="navbar-dropdown" id="nav-compose-contacts">
<a
@@ -350,55 +350,20 @@
hx-get="{% url 'compose_contacts_dropdown' %}?all=1"
hx-target="#nav-compose-contacts"
hx-swap="innerHTML">
Fetch Contacts
Open Contacts
</a>
</div>
</div>
<a class="navbar-item" href="{% url 'tasks_hub' %}">
Tasks
Task Inbox
</a>
<a class="navbar-item" href="{% url 'ai_workspace' %}">
AI
</a>
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Security
</a>
<div class="navbar-dropdown">
<a
class="navbar-item{% if request.resolver_match.url_name == 'encryption_settings' or request.resolver_match.url_name == 'security_settings' %} is-current-route{% endif %}"
href="{% url 'encryption_settings' %}"
>
Encryption
</a>
<a
class="navbar-item{% if request.resolver_match.url_name == 'permission_settings' %} is-current-route{% endif %}"
href="{% url 'permission_settings' %}"
>
Permission
</a>
<a
class="navbar-item{% if request.resolver_match.url_name == 'security_2fa' or request.resolver_match.namespace == 'two_factor' %} is-current-route{% endif %}"
href="{% url 'security_2fa' %}"
>
2FA
</a>
</div>
</div>
<a class="navbar-item" href="{% url 'osint_search' type='page' %}">
Search
</a>
<a class="navbar-item" href="{% url 'queues' type='page' %}">
Queue
</a>
<a class="navbar-item" href="{% url 'osint_workspace' %}">
OSINT
</a>
{% endif %}
<a class="navbar-item add-button">
Install
</a>
</div>
<div class="navbar-end">
@@ -423,15 +388,15 @@
<div class="navbar-item has-dropdown is-hoverable">
<a class="navbar-link">
Storage
Data
</a>
<div class="navbar-dropdown">
<a class="navbar-item" href="{% url 'sessions' type='page' %}">
Sessions
</a>
<a class="navbar-item" href="{% url 'command_routing' %}#bp-documents">
Documents
<a class="navbar-item{% if request.resolver_match.url_name == 'business_plan_inbox' or request.resolver_match.url_name == 'business_plan_editor' %} is-current-route{% endif %}" href="{% url 'business_plan_inbox' %}">
Business Plans
</a>
</div>
</div>
@@ -454,6 +419,19 @@
</a>
{% endif %}
<hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
Security
</div>
<a class="navbar-item{% if request.resolver_match.url_name == 'encryption_settings' or request.resolver_match.url_name == 'security_settings' %} is-current-route{% endif %}" href="{% url 'encryption_settings' %}">
Encryption
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'permission_settings' %} is-current-route{% endif %}" href="{% url 'permission_settings' %}">
Permissions
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'security_2fa' or request.resolver_match.namespace == 'two_factor' %} is-current-route{% endif %}" href="{% url 'security_2fa' %}">
2FA
</a>
<hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
AI
</div>
@@ -470,8 +448,11 @@
<a class="navbar-item{% if request.resolver_match.url_name == 'command_routing' %} is-current-route{% endif %}" href="{% url 'command_routing' %}">
Commands
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'business_plan_inbox' or request.resolver_match.url_name == 'business_plan_editor' %} is-current-route{% endif %}" href="{% url 'business_plan_inbox' %}">
Business Plans
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'tasks_settings' %} is-current-route{% endif %}" href="{% url 'tasks_settings' %}">
Tasks
Task Automation
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'translation_settings' %} is-current-route{% endif %}" href="{% url 'translation_settings' %}">
Translation
@@ -480,6 +461,16 @@
Availability
</a>
<hr class="navbar-divider">
<div class="navbar-item has-text-weight-semibold is-size-7 has-text-grey">
Automation
</div>
<a class="navbar-item{% if request.resolver_match.url_name == 'queues' %} is-current-route{% endif %}" href="{% url 'queues' type='page' %}">
Approvals Queue
</a>
<a class="navbar-item{% if request.resolver_match.url_name == 'osint_workspace' %} is-current-route{% endif %}" href="{% url 'osint_workspace' %}">
OSINT Workspace
</a>
<hr class="navbar-divider">
<a class="navbar-item{% if request.resolver_match.url_name == 'accessibility_settings' %} is-current-route{% endif %}" href="{% url 'accessibility_settings' %}">
Accessibility
</a>
@@ -499,6 +490,7 @@
{% endif %}
{% if user.is_authenticated %}
<button class="button is-light add-button" type="button" style="display:none;">Install App</button>
<a class="button is-dark" href="{% url 'logout' %}">Logout</a>
{% endif %}
@@ -510,8 +502,13 @@
<script>
let deferredPrompt;
const addBtn = document.querySelector('.add-button');
if (addBtn) {
addBtn.style.display = 'none';
}
window.addEventListener('beforeinstallprompt', (e) => {
if (!addBtn) {
return;
}
// Prevent Chrome 67 and earlier from automatically showing the prompt
e.preventDefault();
// Stash the event so it can be triggered later.

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
<h1 class="title is-4">Accessibility</h1>
<div class="box">
@@ -21,5 +21,5 @@
</form>
</div>
</div>
</section>
</section>
{% endblock %}

View File

@@ -227,7 +227,7 @@
</div>
</div>
</article>
<script>
<script>
(function () {
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
button.addEventListener("click", function () {
@@ -297,5 +297,5 @@
});
});
})();
</script>
</script>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
<h1 class="title is-4">Availability Settings</h1>
<form method="post" class="box">
@@ -67,5 +67,5 @@
</table>
</div>
</div>
</section>
</section>
{% endblock %}

View File

@@ -28,7 +28,7 @@
<textarea class="textarea" name="content_markdown" rows="18">{{ document.content_markdown }}</textarea>
<div class="buttons" style="margin-top: 0.75rem;">
<button class="button is-link" type="submit">Save Revision</button>
<a class="button is-light" href="{% url 'command_routing' %}">Back</a>
<a class="button is-light" href="{% url 'business_plan_inbox' %}">Back To Inbox</a>
</div>
</form>
</article>

View File

@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Business Plan Inbox</h1>
<p class="subtitle is-6">Review, filter, and open generated business plan documents.</p>
<article class="notification is-light">
<div class="tags mb-1">
<span class="tag is-light">Total {{ stats.total|default:0 }}</span>
<span class="tag is-warning is-light">Draft {{ stats.draft|default:0 }}</span>
<span class="tag is-success is-light">Final {{ stats.final|default:0 }}</span>
</div>
</article>
<article class="box">
<h2 class="title is-6">Filters</h2>
<form method="get">
<div class="columns is-multiline">
<div class="column is-4">
<label class="label is-size-7">Search</label>
<input class="input is-small" name="q" value="{{ filters.q }}" placeholder="Title, source channel, command profile">
</div>
<div class="column is-3">
<label class="label is-size-7">Status</label>
<div class="select is-small is-fullwidth">
<select name="status">
<option value="">All</option>
<option value="draft" {% if filters.status == "draft" %}selected{% endif %}>Draft</option>
<option value="final" {% if filters.status == "final" %}selected{% endif %}>Final</option>
</select>
</div>
</div>
<div class="column is-3">
<label class="label is-size-7">Service</label>
<div class="select is-small is-fullwidth">
<select name="service">
<option value="">All</option>
{% for service in service_choices %}
<option value="{{ service }}" {% if filters.service == service %}selected{% endif %}>{{ service }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2 is-flex is-align-items-flex-end">
<button class="button is-small is-link is-light" type="submit">Apply</button>
</div>
</div>
</form>
</article>
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
<h2 class="title is-6 mb-0">Documents</h2>
<a class="button is-small is-light" href="{% url 'command_routing' %}">Command Routing</a>
</div>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Service</th>
<th>Channel</th>
<th>Profile</th>
<th>Revisions</th>
<th>Updated</th>
<th></th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr>
<td>{{ doc.title }}</td>
<td>
{% if doc.status == "final" %}
<span class="tag is-success is-light">final</span>
{% else %}
<span class="tag is-warning is-light">draft</span>
{% endif %}
</td>
<td>{{ doc.source_service }}</td>
<td><code>{{ doc.source_channel_identifier|default:"-" }}</code></td>
<td>{% if doc.command_profile %}{{ doc.command_profile.name }}{% else %}-{% endif %}</td>
<td>{{ doc.revision_count }}</td>
<td>{{ doc.updated_at }}</td>
<td>
<a class="button is-small is-link is-light" href="{% url 'business_plan_editor' doc_id=doc.id %}">Open</a>
</td>
</tr>
{% empty %}
<tr><td colspan="8">No business plan documents yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
<h1 class="title is-4">Codex Status</h1>
<p class="subtitle is-6">Global per-user Codex task-sync status, runs, and approvals.</p>
@@ -17,7 +17,7 @@
<p class="help">Healthcheck error: <code>{{ health.error }}</code></p>
{% endif %}
<p class="help">Config snapshot: command=<code>{{ provider_settings.command }}</code>, workspace=<code>{{ provider_settings.workspace_root|default:"-" }}</code>, profile=<code>{{ provider_settings.default_profile|default:"-" }}</code>, instance=<code>{{ provider_settings.instance_label }}</code>, approver=<code>{{ provider_settings.approver_service }} {{ provider_settings.approver_identifier }}</code>.</p>
<p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Settings</a>.</p>
<p class="help"><a href="{% url 'tasks_settings' %}">Edit in Task Automation</a>.</p>
</article>
<article class="box">
@@ -89,7 +89,7 @@
</article>
<article class="box">
<h2 class="title is-6">Permission Queue</h2>
<h2 class="title is-6">Approvals Queue</h2>
<table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>Requested</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Permissions</th><th>Run</th><th>Task</th><th>Actions</th></tr></thead>
<tbody>
@@ -128,8 +128,8 @@
</table>
</article>
</div>
</section>
<style>
</section>
<style>
.codex-inline-stats {
display: flex;
gap: 0.95rem;
@@ -140,5 +140,5 @@
.codex-inline-stats span {
white-space: nowrap;
}
</style>
</style>
{% endblock %}

View File

@@ -393,7 +393,10 @@
{% endfor %}
<article class="box" id="bp-documents">
<h2 class="title is-6">Business Plan Documents</h2>
<div class="is-flex is-justify-content-space-between is-align-items-center mb-2">
<h2 class="title is-6 mb-0">Recent Business Plan Documents</h2>
<a class="button is-small is-link is-light" href="{% url 'business_plan_inbox' %}">Open Business Plan Inbox</a>
</div>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr><th scope="col">Title</th><th scope="col">Status</th><th scope="col">Source</th><th scope="col">Updated</th><th scope="col">Actions</th></tr>

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
{% if show_encryption %}
<div class="columns is-desktop is-variable is-8">
@@ -37,14 +37,24 @@
<h3 class="title is-7 mt-4 mb-2">Security Policy</h3>
<form method="post">
{% csrf_token %}
<input type="hidden" name="encryption_settings_submit" value="1">
<input type="hidden" name="require_omemo" value="0">
<div class="field">
<label class="checkbox">
<input type="checkbox" name="require_omemo"{% if security_settings.require_omemo %} checked{% endif %}>
<input type="checkbox" name="require_omemo" value="1"{% if security_settings.require_omemo %} checked{% endif %}>
Require OMEMO encryption — reject plaintext messages from your XMPP client
</label>
<p class="help is-size-7 has-text-grey mt-1">When enabled, any plaintext XMPP message to the gateway is rejected before command routing.</p>
<p class="help is-size-7 has-text-grey">This is separate from command-scope policy checks such as Require Trusted Fingerprint.</p>
</div>
<input type="hidden" name="encrypt_contact_messages_with_omemo" value="0">
<div class="field mt-3">
<label class="checkbox">
<input type="checkbox" name="encrypt_contact_messages_with_omemo" value="1"{% if security_settings.encrypt_contact_messages_with_omemo %} checked{% endif %}>
Encrypt contact relay messages to your XMPP client with OMEMO
</label>
<p class="help is-size-7 has-text-grey mt-1">When enabled, relay text from contacts is sent with OMEMO when available. If disabled, relay text is sent in plaintext.</p>
</div>
<button class="button is-link is-small" type="submit">Save</button>
</form>
</div>
@@ -70,8 +80,27 @@
</td>
</tr>
<tr>
<th>Contact key</th>
<td><code>{{ omemo_row.latest_client_key|default:"—" }}</code></td>
<th>Client OMEMO</th>
<td>
{% if omemo_client_fingerprint %}
<div>
<div>Client fingerprint: <code>{{ omemo_client_fingerprint }}</code></div>
<p class="help is-size-7 has-text-grey mt-1 mb-0">This is your client OMEMO fingerprint observed by the gateway.</p>
</div>
{% elif omemo_client_key_info.has_ids %}
<div>
{% if omemo_client_key_info.sid %}
<div>Your device ID: <code>{{ omemo_client_key_info.sid }}</code></div>
{% endif %}
{% if omemo_client_key_info.rid %}
<div>Gateway device ID: <code>{{ omemo_client_key_info.rid }}</code></div>
{% endif %}
<p class="help is-size-7 has-text-grey mt-1 mb-0">These IDs identify the OMEMO devices that participated in the encrypted message.</p>
</div>
{% else %}
<code>{{ omemo_row.latest_client_key|default:"—" }}</code>
{% endif %}
</td>
</tr>
<tr>
<th>Contact JID</th>
@@ -101,6 +130,64 @@
</div>
</div>
</div>
<div class="box">
<h2 class="title is-6">OMEMO Trust Management</h2>
<p class="is-size-7 has-text-grey mb-3">
Manage trust for discovered OMEMO keys observed by the gateway.
</p>
<p class="help is-size-7 has-text-grey mb-3">
Note: You are responsible for trusting your own other devices from your other XMPP clients.
</p>
{% if discovered_omemo_keys %}
<table class="table is-fullwidth is-size-7">
<thead>
<tr>
<th>JID</th>
<th>Type</th>
<th>Discovered key</th>
<th>Source</th>
<th>Trusted</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in discovered_omemo_keys %}
<tr>
<td>{{ item.jid }}</td>
<td>{{ item.key_type }}</td>
<td><code>{{ item.key_id }}</code></td>
<td>{{ item.label }}</td>
<td>
{% if item.trusted %}
<span class="tag is-success is-light">trusted</span>
{% else %}
<span class="tag is-light">not trusted</span>
{% endif %}
</td>
<td>
<form method="post" class="is-flex is-align-items-center" style="gap: 0.5rem;">
{% csrf_token %}
<input type="hidden" name="omemo_trust_update" value="1">
<input type="hidden" name="jid" value="{{ item.jid }}">
<input type="hidden" name="key_type" value="{{ item.key_type }}">
<input type="hidden" name="key_id" value="{{ item.key_id }}">
<input type="hidden" name="source" value="{{ item.source }}">
<label class="checkbox">
<input type="checkbox" name="trusted" value="1"{% if item.trusted %} checked{% endif %}>
Trust
</label>
<button class="button is-small is-link is-light" type="submit">Save</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="is-size-7 has-text-grey">No discovered OMEMO keys yet. Send an OMEMO message to populate this list.</p>
{% endif %}
</div>
{% endif %}
{% if show_permission %}
@@ -418,8 +505,8 @@
{% endif %}
</div>
</section>
<template id="channel-rule-template">
</section>
<template id="channel-rule-template">
<div class="channel-rule-row is-flex is-align-items-center mb-2" style="gap: 0.5rem;">
<div class="select is-small">
<select class="scope-editable" data-lock-state="free" name="allowed_channel_service">
@@ -432,8 +519,8 @@
<input class="input is-small scope-editable" data-lock-state="free" name="allowed_channel_pattern" value="" placeholder="m@zm.is* or 1203*">
<button class="button is-small is-light is-danger channel-rule-remove scope-editable" data-lock-state="free" type="button">Remove</button>
</div>
</template>
<script>
</template>
<script>
(function () {
function wireRemoveButtons(scope) {
scope.querySelectorAll(".channel-rule-remove").forEach(function (btn) {
@@ -656,8 +743,8 @@
});
})();
</script>
<style>
</script>
<style>
.policy-tab-panel {
display: none;
}
@@ -705,5 +792,5 @@
color: #ffd0db;
border-color: rgba(241, 70, 104, 0.42);
}
</style>
</style>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
<h1 class="title is-4">{{ category_title }}</h1>
<p class="subtitle is-6">{{ category_description }}</p>
@@ -18,5 +18,5 @@
<p class="is-size-7 has-text-grey">Choose a tab above to open settings in this category.</p>
</div>
</div>
</section>
</section>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<section class="section"><div class="container">
<section class="section"><div class="container">
<h1 class="title is-4">Task #{{ task.reference_code }}: {{ task.title }}</h1>
<p class="subtitle is-6">{{ task.project.name }}{% if task.epic %} / {{ task.epic.name }}{% endif %} · {{ task.status_snapshot }}</p>
<p class="is-size-7 has-text-grey" style="margin-top:-0.65rem; margin-bottom: 0.65rem;">
@@ -104,8 +104,8 @@
</tbody>
</table>
</article>
</div></section>
<style>
</div></section>
<style>
.task-event-payload {
margin-top: 0.35rem;
padding: 0.6rem;
@@ -123,5 +123,5 @@
color: #f5f5f5;
border-color: rgba(200, 200, 200, 0.35);
}
</style>
</style>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<section class="section"><div class="container">
<section class="section"><div class="container">
<h1 class="title is-4">Epic: {{ epic.name }}</h1>
<div class="buttons"><a class="button is-small is-light" href="{% url 'tasks_project' project_id=epic.project_id %}">Back to project</a></div>
<article class="box">
@@ -18,5 +18,5 @@
{% endfor %}
</ul>
</article>
</div></section>
</div></section>
{% endblock %}

View File

@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block content %}
<section class="section"><div class="container">
<h1 class="title is-4">Group Tasks: {{ channel_display_name }}</h1>
<section class="section"><div class="container">
<h1 class="title is-4">Group Task Inbox: {{ channel_display_name }}</h1>
<p class="subtitle is-6">{{ service_label }}</p>
<article class="box">
<h2 class="title is-6">Create Or Map Project</h2>
@@ -65,7 +65,7 @@
<div class="content is-size-7">
<p>This group has no derived tasks yet. To start populating this view:</p>
<ol>
<li>Open <a href="{% url 'tasks_settings' %}?service={{ service }}&identifier={{ identifier|urlencode }}">Task Settings</a> and confirm this chat is mapped under <strong>Group Mapping</strong>.</li>
<li>Open <a href="{% url 'tasks_settings' %}?service={{ service }}&identifier={{ identifier|urlencode }}">Task Automation</a> and confirm this chat is mapped under <strong>Group Mapping</strong>.</li>
<li>Send task-like messages in this group, for example: <code>task: ship v1</code>, <code>todo: write tests</code>, <code>please review PR</code>.</li>
<li>Mark completion explicitly with a phrase + reference, for example: <code>done #12</code>, <code>completed #12</code>, <code>fixed #12</code>.</li>
<li>Refresh this page; new derived tasks and events should appear automatically.</li>
@@ -119,5 +119,5 @@
</tbody>
</table>
</article>
</div></section>
</div></section>
{% endblock %}

View File

@@ -1,11 +1,11 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
<h1 class="title is-4">Tasks</h1>
<h1 class="title is-4">Task Inbox</h1>
<p class="subtitle is-6">Immutable tasks derived from chat activity.</p>
<div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}{% if scope.person_id or scope.service or scope.identifier %}?{% if scope.person_id %}person={{ scope.person_id|urlencode }}{% endif %}{% if scope.service %}{% if scope.person_id %}&{% endif %}service={{ scope.service|urlencode }}{% endif %}{% if scope.identifier %}{% if scope.person_id or scope.service %}&{% endif %}identifier={{ scope.identifier|urlencode }}{% endif %}{% endif %}">Task Settings</a>
<a class="button is-small is-link is-light" href="{% url 'tasks_settings' %}{% if scope.person_id or scope.service or scope.identifier %}?{% if scope.person_id %}person={{ scope.person_id|urlencode }}{% endif %}{% if scope.service %}{% if scope.person_id %}&{% endif %}service={{ scope.service|urlencode }}{% endif %}{% if scope.identifier %}{% if scope.person_id or scope.service %}&{% endif %}identifier={{ scope.identifier|urlencode }}{% endif %}{% endif %}">Task Automation</a>
</div>
<div class="columns is-variable is-5">
<div class="column is-4">
@@ -200,5 +200,5 @@
</div>
</div>
</div>
</section>
</section>
{% endblock %}

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container">
<h1 class="title is-4">Project: {{ project.name }}</h1>
<div class="buttons" style="margin-bottom: 0.75rem;">
@@ -93,5 +93,5 @@
</table>
</article>
</div>
</section>
</section>
{% endblock %}

View File

@@ -1,8 +1,8 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<section class="section">
<div class="container tasks-settings-page">
<h1 class="title is-4">Task Settings</h1>
<h1 class="title is-4">Task Automation</h1>
<p class="subtitle is-6">Project defaults flow into channel overrides. Use Quick Setup for normal operation; open Advanced Setup for full controls.</p>
<div class="notification is-light">
@@ -17,7 +17,7 @@
<section class="block box">
<h2 class="title is-6">Quick Setup</h2>
<p class="help">Creates or updates project + optional epic + channel mapping in one submission.</p>
<p class="help">After setup, view tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>{% if prefill_service and prefill_identifier %} or <a href="{% url 'tasks_group' service=prefill_service identifier=prefill_identifier %}">this group task view</a>{% endif %}.</p>
<p class="help">After setup, view tasks in <a href="{% url 'tasks_hub' %}">Task Inbox</a>{% if prefill_service and prefill_identifier %} or <a href="{% url 'tasks_group' service=prefill_service identifier=prefill_identifier %}">this group task view</a>{% endif %}.</p>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="quick_setup">
@@ -470,7 +470,7 @@
<span class="tag is-success is-light">ok {{ claude_compact_summary.queue_counts.ok }}</span>
</div>
</article>
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Tasks Hub</a>.</p>
<p class="help">Browse all derived tasks in <a href="{% url 'tasks_hub' %}">Task Inbox</a>.</p>
</section>
</div>
<div class="column is-12">
@@ -575,9 +575,9 @@
</div>
</details>
</div>
</section>
</section>
<style>
<style>
.tasks-settings-page .tasks-settings-inline-columns {
margin-left: 0;
margin-right: 0;
@@ -649,9 +649,9 @@
.tasks-settings-page .tasks-settings-grid .table {
table-layout: fixed;
}
</style>
</style>
<script>
<script>
(function () {
const chips = Array.from(document.querySelectorAll('.tasks-settings-page .prefix-chip'));
if (!chips.length) {
@@ -679,5 +679,5 @@
});
});
})();
</script>
</script>
{% endblock %}

View File

@@ -129,7 +129,7 @@
class="button is-small is-info is-light"
onclick="giaWorkspaceQueueSelectedDraft('{{ person.id }}'); return false;">
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
<span>Add To Queue</span>
<span>Queue For Approval</span>
</button>
</div>
</div>

View File

@@ -475,7 +475,7 @@
</button>
<button type="submit" class="button is-info is-light" onclick="giaEngageSetAction('{{ person.id }}', 'queue');">
<span class="icon is-small"><i class="fa-solid fa-inbox-in"></i></span>
<span>Add To Queue</span>
<span>Queue For Approval</span>
</button>
</div>
</form>

View File

@@ -12,7 +12,7 @@
<div class="is-flex is-justify-content-space-between is-align-items-center" style="margin-bottom: 0.75rem; gap: 0.5rem; flex-wrap: wrap;">
<div>
<h3 class="title is-6" style="margin-bottom: 0.15rem;">Outgoing Queue</h3>
<h3 class="title is-6" style="margin-bottom: 0.15rem;">Approvals Queue</h3>
<p class="is-size-7">Review queued drafts and approve or reject each message.</p>
</div>
<span class="tag is-dark is-medium">{{ object_list|length }} pending</span>
@@ -57,7 +57,7 @@
</div>
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; flex-wrap: wrap;">
<small class="has-text-grey">Queue ID: {{ item.id }}</small>
<small class="has-text-grey">Approval ID: {{ item.id }}</small>
<div class="buttons are-small" style="margin: 0;">
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
@@ -92,7 +92,7 @@
</div>
{% else %}
<article class="box" style="padding: 0.8rem; border: 1px dashed rgba(0, 0, 0, 0.25); box-shadow: none;">
<p class="is-size-7 has-text-grey">Queue is empty.</p>
<p class="is-size-7 has-text-grey">Approvals Queue is empty.</p>
</article>
{% endif %}
</div>

View File

@@ -1,5 +1,5 @@
{% include 'mixins/partials/notify.html' %}
<table
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
@@ -151,4 +151,4 @@
</tr>
{% endfor %}
</table>
</table>

View File

@@ -1,8 +1,9 @@
from __future__ import annotations
from unittest.mock import patch
from asgiref.sync import async_to_sync
from django.test import TestCase
from unittest.mock import patch
from core.messaging.ai import run_prompt
from core.models import AI, AIRunLog, User

View File

@@ -6,9 +6,9 @@ from django.test import TestCase
from core.models import (
ChatSession,
ContactAvailabilityEvent,
Message,
Person,
PersonIdentifier,
Message,
User,
)
from core.presence.inference import now_ms
@@ -16,7 +16,9 @@ from core.presence.inference import now_ms
class BackfillContactAvailabilityCommandTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("backfill-user", "backfill@example.com", "x")
self.user = User.objects.create_user(
"backfill-user", "backfill@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Backfill Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
@@ -24,7 +26,9 @@ class BackfillContactAvailabilityCommandTests(TestCase):
service="signal",
identifier="+15551234567",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
def test_backfill_creates_message_and_read_receipt_availability_events(self):
base_ts = now_ms()
@@ -58,7 +62,9 @@ class BackfillContactAvailabilityCommandTests(TestCase):
)
events = list(
ContactAvailabilityEvent.objects.filter(user=self.user).order_by("ts", "source_kind")
ContactAvailabilityEvent.objects.filter(user=self.user).order_by(
"ts", "source_kind"
)
)
self.assertEqual(3, len(events))
self.assertTrue(any(row.source_kind == "message_in" for row in events))

View File

@@ -123,7 +123,9 @@ class BPFallbackTests(TransactionTestCase):
run = CommandRun.objects.get(trigger_message=trigger, profile=self.profile)
self.assertEqual("failed", run.status)
self.assertIn("bp_ai_failed", str(run.error))
self.assertFalse(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
self.assertFalse(
BusinessPlanDocument.objects.filter(trigger_message=trigger).exists()
)
def test_bp_uses_same_ai_selection_order_as_compose(self):
AI.objects.create(

View File

@@ -35,7 +35,9 @@ class BPSubcommandTests(TransactionTestCase):
service="whatsapp",
identifier="120363402761690215",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.profile = CommandProfile.objects.create(
user=self.user,
slug="bp",
@@ -96,13 +98,19 @@ class BPSubcommandTests(TransactionTestCase):
source_service="whatsapp",
source_chat_id="120363402761690215",
)
with patch("core.commands.handlers.bp.ai_runner.run_prompt", new=AsyncMock()) as mocked_ai:
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
with patch(
"core.commands.handlers.bp.ai_runner.run_prompt", new=AsyncMock()
) as mocked_ai:
result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok)
mocked_ai.assert_not_awaited()
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertEqual("direct body", doc.content_markdown)
self.assertEqual("Generated from 1 message.", doc.structured_payload.get("annotation"))
self.assertEqual(
"Generated from 1 message.", doc.structured_payload.get("annotation")
)
def test_set_reply_only_uses_anchor(self):
anchor = Message.objects.create(
@@ -124,11 +132,15 @@ class BPSubcommandTests(TransactionTestCase):
source_chat_id="120363402761690215",
reply_to=anchor,
)
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok)
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertEqual("anchor body", doc.content_markdown)
self.assertEqual("Generated from 1 message.", doc.structured_payload.get("annotation"))
self.assertEqual(
"Generated from 1 message.", doc.structured_payload.get("annotation")
)
def test_set_reply_plus_addendum_uses_divider(self):
anchor = Message.objects.create(
@@ -150,7 +162,9 @@ class BPSubcommandTests(TransactionTestCase):
source_chat_id="120363402761690215",
reply_to=anchor,
)
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok)
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertIn("base body", doc.content_markdown)
@@ -171,7 +185,9 @@ class BPSubcommandTests(TransactionTestCase):
source_service="whatsapp",
source_chat_id="120363402761690215",
)
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertFalse(result.ok)
self.assertEqual("failed", result.status)
self.assertEqual("bp_set_range_requires_reply_target", result.error)
@@ -205,8 +221,12 @@ class BPSubcommandTests(TransactionTestCase):
source_chat_id="120363402761690215",
reply_to=anchor,
)
result = async_to_sync(BPCommandHandler().execute)(self._ctx(trigger, trigger.text))
result = async_to_sync(BPCommandHandler().execute)(
self._ctx(trigger, trigger.text)
)
self.assertTrue(result.ok)
doc = BusinessPlanDocument.objects.get(trigger_message=trigger)
self.assertEqual("line 1\n(no text)\n#bp set range#", doc.content_markdown)
self.assertEqual("Generated from 3 messages.", doc.structured_payload.get("annotation"))
self.assertEqual(
"Generated from 3 messages.", doc.structured_payload.get("annotation")
)

View File

@@ -55,7 +55,9 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_timeout_maps_to_failed_result(self, run_mock):
run_mock.side_effect = TimeoutExpired(cmd=["claude"], timeout=10)
result = self.provider.append_update({"command": "claude", "timeout_seconds": 10}, {"task_id": "t1"})
result = self.provider.append_update(
{"command": "claude", "timeout_seconds": 10}, {"task_id": "t1"}
)
self.assertFalse(result.ok)
self.assertIn("timeout", result.error)
@@ -70,7 +72,9 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
result = self.provider.append_update({"command": "claude"}, {"task_id": "t1"})
self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", (result.payload or {}).get("parsed_status"))
self.assertEqual(
"requires_approval", (result.payload or {}).get("parsed_status")
)
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
@@ -99,7 +103,9 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
self.assertEqual(["claude", "task-sync", "create"], second[:3])
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(self, run_mock):
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(
self, run_mock
):
run_mock.side_effect = [
CompletedProcess(
args=[],
@@ -124,8 +130,13 @@ class ClaudeCLITaskProviderTests(SimpleTestCase):
)
self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", str((result.payload or {}).get("status") or ""))
self.assertEqual("builtin_task_sync_stub", str((result.payload or {}).get("fallback_mode") or ""))
self.assertEqual(
"requires_approval", str((result.payload or {}).get("status") or "")
)
self.assertEqual(
"builtin_task_sync_stub",
str((result.payload or {}).get("fallback_mode") or ""),
)
@patch("core.tasks.providers.claude_cli.subprocess.run")
def test_builtin_stub_approval_response_returns_ok(self, run_mock):

View File

@@ -8,10 +8,10 @@ from core.commands.engine import process_inbound_message
from core.commands.handlers.claude import parse_claude_command
from core.models import (
ChatSession,
CommandChannelBinding,
CommandProfile,
CodexPermissionRequest,
CodexRun,
CommandChannelBinding,
CommandProfile,
DerivedTask,
ExternalSyncEvent,
Message,
@@ -45,7 +45,9 @@ class ClaudeCommandParserTests(TestCase):
class ClaudeCommandExecutionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("claude-cmd-user", "claude-cmd@example.com", "x")
self.user = User.objects.create_user(
"claude-cmd-user", "claude-cmd@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Claude Cmd")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
@@ -53,7 +55,9 @@ class ClaudeCommandExecutionTests(TestCase):
service="web",
identifier="web-chan-1",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(user=self.user, name="Project A")
self.task = DerivedTask.objects.create(
user=self.user,
@@ -202,7 +206,9 @@ class ClaudeCommandExecutionTests(TestCase):
channel_identifier="approver-chan",
enabled=True,
)
trigger = self._msg("#claude approve cl-ak-123#", source_chat_id="approver-chan")
trigger = self._msg(
"#claude approve cl-ak-123#", source_chat_id="approver-chan"
)
results = async_to_sync(process_inbound_message)(
CommandContext(
service="web",

View File

@@ -55,7 +55,9 @@ class CodexCLITaskProviderTests(SimpleTestCase):
@patch("core.tasks.providers.codex_cli.subprocess.run")
def test_timeout_maps_to_failed_result(self, run_mock):
run_mock.side_effect = TimeoutExpired(cmd=["codex"], timeout=10)
result = self.provider.append_update({"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"})
result = self.provider.append_update(
{"command": "codex", "timeout_seconds": 10}, {"task_id": "t1"}
)
self.assertFalse(result.ok)
self.assertIn("timeout", result.error)
@@ -70,7 +72,9 @@ class CodexCLITaskProviderTests(SimpleTestCase):
result = self.provider.append_update({"command": "codex"}, {"task_id": "t1"})
self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", (result.payload or {}).get("parsed_status"))
self.assertEqual(
"requires_approval", (result.payload or {}).get("parsed_status")
)
@patch("core.tasks.providers.codex_cli.subprocess.run")
def test_retries_with_positional_op_when_flag_unsupported(self, run_mock):
@@ -99,7 +103,9 @@ class CodexCLITaskProviderTests(SimpleTestCase):
self.assertEqual(["codex", "task-sync", "create"], second[:3])
@patch("core.tasks.providers.codex_cli.subprocess.run")
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(self, run_mock):
def test_falls_back_to_builtin_approval_stub_when_no_task_sync_contract(
self, run_mock
):
run_mock.side_effect = [
CompletedProcess(
args=[],
@@ -124,8 +130,13 @@ class CodexCLITaskProviderTests(SimpleTestCase):
)
self.assertTrue(result.ok)
self.assertTrue(bool((result.payload or {}).get("requires_approval")))
self.assertEqual("requires_approval", str((result.payload or {}).get("status") or ""))
self.assertEqual("builtin_task_sync_stub", str((result.payload or {}).get("fallback_mode") or ""))
self.assertEqual(
"requires_approval", str((result.payload or {}).get("status") or "")
)
self.assertEqual(
"builtin_task_sync_stub",
str((result.payload or {}).get("fallback_mode") or ""),
)
@patch("core.tasks.providers.codex_cli.subprocess.run")
def test_builtin_stub_approval_response_returns_ok(self, run_mock):

View File

@@ -8,10 +8,10 @@ from core.commands.engine import process_inbound_message
from core.commands.handlers.codex import parse_codex_command
from core.models import (
ChatSession,
CommandChannelBinding,
CommandProfile,
CodexPermissionRequest,
CodexRun,
CommandChannelBinding,
CommandProfile,
DerivedTask,
ExternalSyncEvent,
Message,
@@ -41,7 +41,9 @@ class CodexCommandParserTests(TestCase):
class CodexCommandExecutionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("codex-cmd-user", "codex-cmd@example.com", "x")
self.user = User.objects.create_user(
"codex-cmd-user", "codex-cmd@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Codex Cmd")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
@@ -49,7 +51,9 @@ class CodexCommandExecutionTests(TestCase):
service="web",
identifier="web-chan-1",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(user=self.user, name="Project A")
self.task = DerivedTask.objects.create(
user=self.user,
@@ -126,7 +130,10 @@ class CodexCommandExecutionTests(TestCase):
self.assertEqual("waiting_approval", run.status)
event = ExternalSyncEvent.objects.order_by("-created_at").first()
self.assertEqual("waiting_approval", event.status)
self.assertEqual("default", str((event.payload or {}).get("provider_payload", {}).get("mode") or ""))
self.assertEqual(
"default",
str((event.payload or {}).get("provider_payload", {}).get("mode") or ""),
)
self.assertTrue(
CodexPermissionRequest.objects.filter(
user=self.user,
@@ -167,7 +174,10 @@ class CodexCommandExecutionTests(TestCase):
source_service="web",
source_channel="web-chan-1",
status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
request_payload={
"action": "append_update",
"provider_payload": {"task_id": str(self.task.id)},
},
result_payload={},
)
req = CodexPermissionRequest.objects.create(
@@ -207,7 +217,9 @@ class CodexCommandExecutionTests(TestCase):
self.assertEqual("approved_waiting_resume", run.status)
self.assertEqual("ok", waiting_event.status)
self.assertTrue(
ExternalSyncEvent.objects.filter(idempotency_key="codex_approval:ak-123:approved", status="pending").exists()
ExternalSyncEvent.objects.filter(
idempotency_key="codex_approval:ak-123:approved", status="pending"
).exists()
)
def test_approve_pre_submit_request_queues_original_action(self):
@@ -226,7 +238,10 @@ class CodexCommandExecutionTests(TestCase):
source_service="web",
source_channel="web-chan-1",
status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
request_payload={
"action": "append_update",
"provider_payload": {"task_id": str(self.task.id)},
},
result_payload={},
)
CodexPermissionRequest.objects.create(
@@ -264,7 +279,11 @@ class CodexCommandExecutionTests(TestCase):
)
self.assertEqual(1, len(results))
self.assertTrue(results[0].ok)
resume = ExternalSyncEvent.objects.filter(idempotency_key="codex_cmd:resume:1").first()
resume = ExternalSyncEvent.objects.filter(
idempotency_key="codex_cmd:resume:1"
).first()
self.assertIsNotNone(resume)
self.assertEqual("pending", resume.status)
self.assertEqual("append_update", str((resume.payload or {}).get("action") or ""))
self.assertEqual(
"append_update", str((resume.payload or {}).get("action") or "")
)

View File

@@ -5,13 +5,22 @@ from unittest.mock import patch
from django.test import TestCase
from core.management.commands.codex_worker import Command as CodexWorkerCommand
from core.models import CodexPermissionRequest, CodexRun, ExternalSyncEvent, TaskProject, TaskProviderConfig, User
from core.models import (
CodexPermissionRequest,
CodexRun,
ExternalSyncEvent,
TaskProject,
TaskProviderConfig,
User,
)
from core.tasks.providers.base import ProviderResult
class CodexWorkerPhase1Tests(TestCase):
def setUp(self):
self.user = User.objects.create_user("codex-worker-user", "codex-worker@example.com", "x")
self.user = User.objects.create_user(
"codex-worker-user", "codex-worker@example.com", "x"
)
self.project = TaskProject.objects.create(user=self.user, name="Worker Project")
self.cfg = TaskProviderConfig.objects.create(
user=self.user,
@@ -57,7 +66,9 @@ class CodexWorkerPhase1Tests(TestCase):
run_in_worker = True
def append_update(self, config, payload):
return ProviderResult(ok=True, payload={"status": "ok", "summary": "done"})
return ProviderResult(
ok=True, payload={"status": "ok", "summary": "done"}
)
create_task = mark_complete = link_task = append_update
@@ -71,7 +82,9 @@ class CodexWorkerPhase1Tests(TestCase):
self.assertEqual("done", str(run.result_payload.get("summary") or ""))
@patch("core.management.commands.codex_worker.get_provider")
def test_requires_approval_moves_to_waiting_and_creates_permission_request(self, get_provider_mock):
def test_requires_approval_moves_to_waiting_and_creates_permission_request(
self, get_provider_mock
):
run = CodexRun.objects.create(
user=self.user,
project=self.project,
@@ -128,7 +141,10 @@ class CodexWorkerPhase1Tests(TestCase):
user=self.user,
provider="codex_cli",
status="waiting_approval",
payload={"action": "append_update", "provider_payload": {"mode": "default"}},
payload={
"action": "append_update",
"provider_payload": {"mode": "default"},
},
error="",
)
run = CodexRun.objects.create(
@@ -169,7 +185,9 @@ class CodexWorkerPhase1Tests(TestCase):
run_in_worker = True
def append_update(self, config, payload):
return ProviderResult(ok=True, payload={"status": "ok", "summary": "resumed"})
return ProviderResult(
ok=True, payload={"status": "ok", "summary": "resumed"}
)
create_task = mark_complete = link_task = append_update

View File

@@ -89,7 +89,9 @@ class CommandSecurityPolicyTests(TestCase):
)
self.assertEqual(1, len(results))
self.assertEqual("skipped", results[0].status)
self.assertTrue(str(results[0].error).startswith("policy_denied:service_not_allowed"))
self.assertTrue(
str(results[0].error).startswith("policy_denied:service_not_allowed")
)
def test_gateway_scope_can_require_trusted_omemo_key(self):
CommandSecurityPolicy.objects.create(
@@ -120,7 +122,9 @@ class CommandSecurityPolicyTests(TestCase):
channel_identifier="policy-user@zm.is",
sender_identifier="policy-user@zm.is/phone",
message_text=".tasks list",
message_meta={"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}},
message_meta={
"xmpp": {"omemo_status": "detected", "omemo_client_key": "sid:abc"}
},
payload={},
),
routes=[

View File

@@ -9,8 +9,8 @@ from core.commands.base import CommandContext
from core.commands.handlers.bp import BPCommandHandler
from core.commands.policies import ensure_variant_policies_for_profile
from core.models import (
BusinessPlanDocument,
AI,
BusinessPlanDocument,
ChatSession,
CommandAction,
CommandChannelBinding,
@@ -37,7 +37,9 @@ class CommandVariantPolicyTests(TransactionTestCase):
service="whatsapp",
identifier="120363402761690215",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.profile = CommandProfile.objects.create(
user=self.user,
slug="bp",
@@ -109,7 +111,9 @@ class CommandVariantPolicyTests(TransactionTestCase):
def test_bp_primary_can_run_in_verbatim_mode_without_ai(self):
ensure_variant_policies_for_profile(self.profile)
policy = CommandVariantPolicy.objects.get(profile=self.profile, variant_key="bp")
policy = CommandVariantPolicy.objects.get(
profile=self.profile, variant_key="bp"
)
policy.generation_mode = "verbatim"
policy.send_plan_to_egress = False
policy.send_status_to_source = False
@@ -143,7 +147,9 @@ class CommandVariantPolicyTests(TransactionTestCase):
def test_bp_set_ai_mode_ignores_template(self):
ensure_variant_policies_for_profile(self.profile)
policy = CommandVariantPolicy.objects.get(profile=self.profile, variant_key="bp_set")
policy = CommandVariantPolicy.objects.get(
profile=self.profile, variant_key="bp_set"
)
policy.generation_mode = "ai"
policy.send_plan_to_egress = False
policy.send_status_to_source = False
@@ -222,4 +228,6 @@ class CommandVariantPolicyTests(TransactionTestCase):
self.assertTrue(result.ok)
source_status.assert_awaited()
self.assertEqual(1, binding_send.await_count)
self.assertFalse(BusinessPlanDocument.objects.filter(trigger_message=trigger).exists())
self.assertFalse(
BusinessPlanDocument.objects.filter(trigger_message=trigger).exists()
)

View File

@@ -13,7 +13,9 @@ class ComposeReactTests(TestCase):
self.user = User.objects.create_user("compose-react", "react@example.com", "pw")
self.client.force_login(self.user)
def _build_message(self, *, service: str, identifier: str, source_message_id: str = ""):
def _build_message(
self, *, service: str, identifier: str, source_message_id: str = ""
):
person = Person.objects.create(user=self.user, name=f"{service} person")
person_identifier = PersonIdentifier.objects.create(
user=self.user,

View File

@@ -6,6 +6,7 @@ Signal coverage is in test_signal_reply_send.py. This file fills the gaps
for WhatsApp and XMPP, and verifies the shared reply_sync infrastructure
works correctly for both services.
"""
from __future__ import annotations
import xml.etree.ElementTree as ET
@@ -25,11 +26,11 @@ from core.messaging import history, reply_sync
from core.models import ChatSession, Message, Person, PersonIdentifier, User
from core.presence.inference import now_ms
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _fake_stanza(xml_text: str) -> SimpleNamespace:
"""Minimal stanza-like object with an .xml attribute."""
return SimpleNamespace(xml=ET.fromstring(xml_text))
@@ -39,6 +40,7 @@ def _fake_stanza(xml_text: str) -> SimpleNamespace:
# WhatsApp — reply extraction (pure, no DB)
# ---------------------------------------------------------------------------
class WhatsAppReplyExtractionTests(SimpleTestCase):
def test_extract_reply_ref_from_contextinfo_stanza_id(self):
payload = {
@@ -87,6 +89,7 @@ class WhatsAppReplyExtractionTests(SimpleTestCase):
# WhatsApp — reply resolution (requires DB)
# ---------------------------------------------------------------------------
class WhatsAppReplyResolutionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
@@ -178,7 +181,9 @@ class WhatsAppReplyResolutionTests(TestCase):
)
self.anchor.refresh_from_db()
reactions = list((self.anchor.receipt_payload or {}).get("reactions") or [])
removed = [r for r in reactions if r.get("emoji") == "👍" and not r.get("removed")]
removed = [
r for r in reactions if r.get("emoji") == "👍" and not r.get("removed")
]
self.assertEqual(0, len(removed))
@@ -186,6 +191,7 @@ class WhatsAppReplyResolutionTests(TestCase):
# WhatsApp — outbound reply metadata
# ---------------------------------------------------------------------------
class WhatsAppOutboundReplyTests(TestCase):
def test_transport_passes_reply_metadata_to_whatsapp_api(self):
mock_client = MagicMock()
@@ -222,6 +228,7 @@ class WhatsAppOutboundReplyTests(TestCase):
# XMPP — reaction extraction (pure, no DB)
# ---------------------------------------------------------------------------
class XMPPReactionExtractionTests(SimpleTestCase):
def test_extract_xep_0444_reaction(self):
stanza = _fake_stanza(
@@ -276,6 +283,7 @@ class XMPPReactionExtractionTests(SimpleTestCase):
# XMPP — reply extraction (pure, no DB)
# ---------------------------------------------------------------------------
class XMPPReplyExtractionTests(SimpleTestCase):
def test_extract_reply_target_id_from_xep_0461_stanza(self):
stanza = _fake_stanza(
@@ -304,7 +312,9 @@ class XMPPReplyExtractionTests(SimpleTestCase):
self.assertEqual("user@zm.is/mobile", ref.get("reply_source_chat_id"))
def test_extract_reply_ref_returns_empty_for_missing_id(self):
ref = reply_sync.extract_reply_ref("xmpp", {"reply_source_chat_id": "user@zm.is"})
ref = reply_sync.extract_reply_ref(
"xmpp", {"reply_source_chat_id": "user@zm.is"}
)
self.assertEqual({}, ref)
@@ -312,6 +322,7 @@ class XMPPReplyExtractionTests(SimpleTestCase):
# XMPP — reply resolution (requires DB)
# ---------------------------------------------------------------------------
class XMPPReplyResolutionTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(

View File

@@ -1,5 +1,5 @@
from io import StringIO
import time
from io import StringIO
from django.core.management import call_command
from django.test import TestCase, override_settings
@@ -24,7 +24,9 @@ class EventProjectionShadowTests(TestCase):
service="signal",
identifier="+15555550333",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
def test_shadow_compare_has_zero_mismatch_when_projection_matches(self):
message = Message.objects.create(

View File

@@ -7,13 +7,13 @@ from django.test import TestCase, override_settings
from core.mcp.tools import execute_tool, tool_specs
from core.models import (
AIRequest,
DerivedTask,
DerivedTaskEvent,
MCPToolAuditLog,
MemoryItem,
TaskProject,
User,
WorkspaceConversation,
DerivedTask,
DerivedTaskEvent,
)
@@ -80,9 +80,13 @@ class MCPToolTests(TestCase):
first_hit = (memory_payload.get("hits") or [{}])[0]
self.assertEqual(str(self.memory.id), str(first_hit.get("memory_id")))
list_payload = execute_tool("tasks.list", {"user_id": self.user.id, "limit": 10})
list_payload = execute_tool(
"tasks.list", {"user_id": self.user.id, "limit": 10}
)
self.assertEqual(1, int(list_payload.get("count") or 0))
self.assertEqual(str(self.task.id), str((list_payload.get("items") or [{}])[0].get("id")))
self.assertEqual(
str(self.task.id), str((list_payload.get("items") or [{}])[0].get("id"))
)
search_payload = execute_tool(
"tasks.search",
@@ -90,9 +94,13 @@ class MCPToolTests(TestCase):
)
self.assertEqual(1, int(search_payload.get("count") or 0))
events_payload = execute_tool("tasks.events", {"task_id": str(self.task.id), "limit": 5})
events_payload = execute_tool(
"tasks.events", {"task_id": str(self.task.id), "limit": 5}
)
self.assertEqual(1, int(events_payload.get("count") or 0))
self.assertEqual("created", str((events_payload.get("items") or [{}])[0].get("event_type")))
self.assertEqual(
"created", str((events_payload.get("items") or [{}])[0].get("event_type"))
)
def test_memory_proposal_review_flow(self):
propose_payload = execute_tool(
@@ -182,7 +190,9 @@ class MCPToolTests(TestCase):
"note": "Implemented wiki tooling.",
},
)
self.assertEqual("progress", str((note_payload.get("event") or {}).get("event_type")))
self.assertEqual(
"progress", str((note_payload.get("event") or {}).get("event_type"))
)
artifact_payload = execute_tool(
"tasks.link_artifact",

View File

@@ -7,7 +7,13 @@ from django.core.management import call_command
from django.test import TestCase
from django.utils import timezone
from core.models import MemoryChangeRequest, MemoryItem, MessageEvent, User, WorkspaceConversation
from core.models import (
MemoryChangeRequest,
MemoryItem,
MessageEvent,
User,
WorkspaceConversation,
)
class MemoryPipelineCommandTests(TestCase):
@@ -46,7 +52,9 @@ class MemoryPipelineCommandTests(TestCase):
self.assertIn("memory-suggest-from-messages", rendered)
self.assertGreaterEqual(MemoryItem.objects.filter(user=self.user).count(), 1)
self.assertGreaterEqual(
MemoryChangeRequest.objects.filter(user=self.user, status="pending").count(),
MemoryChangeRequest.objects.filter(
user=self.user, status="pending"
).count(),
1,
)

View File

@@ -6,10 +6,9 @@ from django.test import TestCase
from core.commands.base import CommandContext
from core.commands.engine import _matches_trigger, process_inbound_message
from core.messaging.reply_sync import extract_reply_ref, resolve_reply_target
from core.views.compose import _command_options_for_channel
from core.models import (
ChatTaskSource,
ChatSession,
ChatTaskSource,
CommandAction,
CommandChannelBinding,
CommandProfile,
@@ -19,6 +18,7 @@ from core.models import (
PersonIdentifier,
User,
)
from core.views.compose import _command_options_for_channel
class Phase1ReplyResolutionTests(TestCase):
@@ -402,7 +402,9 @@ class Phase1CommandEngineTests(TestCase):
if profile is None:
return
self.assertEqual(3, CommandAction.objects.filter(profile=profile).count())
self.assertEqual(3, CommandVariantPolicy.objects.filter(profile=profile).count())
self.assertEqual(
3, CommandVariantPolicy.objects.filter(profile=profile).count()
)
self.assertEqual(
2,
CommandChannelBinding.objects.filter(
@@ -436,7 +438,9 @@ class Phase1CommandEngineTests(TestCase):
self.assertEqual(1, len(second_results))
self.assertEqual("reply_required", second_results[0].error)
self.assertEqual(3, CommandAction.objects.filter(profile=profile).count())
self.assertEqual(3, CommandVariantPolicy.objects.filter(profile=profile).count())
self.assertEqual(
3, CommandVariantPolicy.objects.filter(profile=profile).count()
)
self.assertEqual(
2,
CommandChannelBinding.objects.filter(

View File

@@ -21,7 +21,9 @@ from core.presence.inference import now_ms
class PresenceEngineTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("presence-user", "presence@example.com", "x")
self.user = User.objects.create_user(
"presence-user", "presence@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Presence Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
@@ -57,7 +59,9 @@ class PresenceEngineTests(TestCase):
)
)
self.assertIsNotNone(event)
self.assertEqual(1, ContactAvailabilityEvent.objects.filter(user=self.user).count())
self.assertEqual(
1, ContactAvailabilityEvent.objects.filter(user=self.user).count()
)
self.assertEqual("available", event.availability_state)
def test_inactivity_transitions_to_fading(self):
@@ -106,7 +110,9 @@ class PresenceEngineTests(TestCase):
at_ts=base_ts + 60_000,
)
self.assertIsNone(fade_event)
self.assertEqual(1, ContactAvailabilityEvent.objects.filter(user=self.user).count())
self.assertEqual(
1, ContactAvailabilityEvent.objects.filter(user=self.user).count()
)
def test_adjacent_same_state_events_extend_single_span(self):
ts0 = now_ms()
@@ -134,7 +140,9 @@ class PresenceEngineTests(TestCase):
ts=ts0 + 5_000,
)
)
spans = list(ContactAvailabilitySpan.objects.filter(user=self.user).order_by("start_ts"))
spans = list(
ContactAvailabilitySpan.objects.filter(user=self.user).order_by("start_ts")
)
self.assertEqual(1, len(spans))
self.assertEqual(ts0, spans[0].start_ts)
self.assertEqual(ts0 + 5_000, spans[0].end_ts)

View File

@@ -62,12 +62,21 @@ class ReactionNormalizationTests(TestCase):
self.assertEqual(str(exact_message.id), str(updated.id))
exact_message.refresh_from_db()
near_message.refresh_from_db()
self.assertEqual(1, len((exact_message.receipt_payload or {}).get("reactions") or []))
self.assertEqual(
1, len((exact_message.receipt_payload or {}).get("reactions") or [])
)
self.assertEqual(
"exact_source_message_id_ts",
str((exact_message.receipt_payload or {}).get("reaction_last_match_strategy") or ""),
str(
(exact_message.receipt_payload or {}).get(
"reaction_last_match_strategy"
)
or ""
),
)
self.assertEqual(
0, len((near_message.receipt_payload or {}).get("reactions") or [])
)
self.assertEqual(0, len((near_message.receipt_payload or {}).get("reactions") or []))
def test_remove_without_emoji_is_audited_not_active(self):
message = Message.objects.create(

View File

@@ -28,7 +28,9 @@ class ReconcileWorkspaceMetricHistoryCommandTests(TestCase):
service="whatsapp",
identifier="15551230000@s.whatsapp.net",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
base_ts = 1_700_000_000_000
for idx in range(10):
inbound = idx % 2 == 0

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch
from django.urls import reverse
from django.test import TestCase
from django.urls import reverse
from core.models import User

View File

@@ -1,8 +1,7 @@
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
from unittest.mock import Mock
from unittest.mock import AsyncMock, Mock, patch
from asgiref.sync import async_to_sync
from django.conf import settings
@@ -175,11 +174,15 @@ class SignalInboundReplyLinkTests(TransactionTestCase):
}
async_to_sync(client._process_raw_inbound_event)(json.dumps(payload))
created = Message.objects.filter(
created = (
Message.objects.filter(
user=self.user,
session=self.session,
text="reply inbound s3",
).order_by("-ts").first()
)
.order_by("-ts")
.first()
)
self.assertIsNotNone(created)
self.assertEqual(self.anchor.id, created.reply_to_id)
self.assertEqual("1772545458187", created.reply_source_message_id)
@@ -222,7 +225,9 @@ class SignalInboundReplyLinkTests(TransactionTestCase):
"Expected Signal heart reaction to be applied to anchor receipt payload.",
)
def test_process_raw_inbound_event_applies_sync_reaction_using_destination_fallback(self):
def test_process_raw_inbound_event_applies_sync_reaction_using_destination_fallback(
self,
):
fake_ur = Mock()
fake_ur.message_received = AsyncMock(return_value=None)
fake_ur.xmpp = Mock()
@@ -253,7 +258,7 @@ class SignalInboundReplyLinkTests(TransactionTestCase):
"emoji": "🔥",
"targetSentTimestamp": 1772545458187,
}
}
},
}
},
}
@@ -352,7 +357,9 @@ class SignalRuntimeCommandWritebackTests(TestCase):
service="signal",
identifier="+15550003000",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.message = Message.objects.create(
user=self.user,
session=self.session,

View File

@@ -5,7 +5,6 @@ from unittest.mock import AsyncMock, patch
from asgiref.sync import async_to_sync
from django.test import TestCase, override_settings
from django.utils import timezone
from core.models import (
ChatSession,
@@ -88,7 +87,9 @@ class TaskEnginePlan09Tests(TestCase):
service="signal",
identifier="+15559001234",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(user=self.user, name="Plan09 Project")
ChatTaskSource.objects.create(
user=self.user,
@@ -133,7 +134,9 @@ class TaskEnginePlan09Tests(TestCase):
async_to_sync(process_inbound_task_intelligence)(seed)
cmd = self._msg(".task list", ts=1002)
async_to_sync(process_inbound_task_intelligence)(cmd)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
payloads = [
str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list
]
self.assertTrue(any("open tasks" in row.lower() for row in payloads))
@patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock)
@@ -143,7 +146,9 @@ class TaskEnginePlan09Tests(TestCase):
task = DerivedTask.objects.get(origin_message=seed)
cmd = self._msg(f".task show #{task.reference_code}", ts=1004)
async_to_sync(process_inbound_task_intelligence)(cmd)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
payloads = [
str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list
]
self.assertTrue(any("deploy new version" in row.lower() for row in payloads))
self.assertTrue(any(str(task.reference_code) in row for row in payloads))
@@ -157,9 +162,13 @@ class TaskEnginePlan09Tests(TestCase):
task.refresh_from_db()
self.assertEqual("completed", task.status_snapshot)
self.assertTrue(
DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").exists()
DerivedTaskEvent.objects.filter(
task=task, event_type="completion_marked"
).exists()
)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
payloads = [
str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list
]
self.assertTrue(any("completed" in row.lower() for row in payloads))
def test_dot_task_complete_creates_audit_event(self):
@@ -169,7 +178,9 @@ class TaskEnginePlan09Tests(TestCase):
with patch("core.tasks.engine.send_message_raw", new_callable=AsyncMock):
cmd = self._msg(f".task complete #{task.reference_code}", ts=1008)
async_to_sync(process_inbound_task_intelligence)(cmd)
event = DerivedTaskEvent.objects.filter(task=task, event_type="completion_marked").first()
event = DerivedTaskEvent.objects.filter(
task=task, event_type="completion_marked"
).first()
self.assertIsNotNone(event)
self.assertIn("command", str(event.payload or {}).lower())
@@ -185,7 +196,9 @@ class TaskEngineMemoryContextTests(TestCase):
service="whatsapp",
identifier="447700900001@s.whatsapp.net",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(user=self.user, name="Mem Project")
ChatTaskSource.objects.create(
user=self.user,
@@ -218,8 +231,16 @@ class TaskEngineMemoryContextTests(TestCase):
from core.models import CodexRun
m = self._msg("task: fix authentication bug", ts=2001)
fake_memory = [{"id": "mem-1", "memory_kind": "fact", "content": {"text": "prefers short summaries"}}]
with patch("core.tasks.engine.retrieve_memories_for_prompt", return_value=fake_memory):
fake_memory = [
{
"id": "mem-1",
"memory_kind": "fact",
"content": {"text": "prefers short summaries"},
}
]
with patch(
"core.tasks.engine.retrieve_memories_for_prompt", return_value=fake_memory
):
async_to_sync(process_inbound_task_intelligence)(m)
task = DerivedTask.objects.filter(origin_message=m).first()
self.assertIsNotNone(task)
@@ -227,5 +248,7 @@ class TaskEngineMemoryContextTests(TestCase):
self.assertIsNotNone(run, "Expected CodexRun created for task")
provider_payload = (run.request_payload or {}).get("provider_payload") or {}
memory_context = provider_payload.get("memory_context")
self.assertIsNotNone(memory_context, "Expected memory_context in CodexRun provider payload")
self.assertIsNotNone(
memory_context, "Expected memory_context in CodexRun provider payload"
)
self.assertEqual(1, len(memory_context))

View File

@@ -48,7 +48,9 @@ class TasksPagesManagementTests(TestCase):
self.assertEqual(200, response.status_code)
project = TaskProject.objects.get(user=self.user, name="Ops")
self.assertIsNotNone(project)
self.assertFalse(ChatTaskSource.objects.filter(user=self.user, project=project).exists())
self.assertFalse(
ChatTaskSource.objects.filter(user=self.user, project=project).exists()
)
def test_tasks_hub_can_map_identifier_to_selected_project(self):
project = TaskProject.objects.create(user=self.user, name="Mapped")
@@ -108,7 +110,9 @@ class TasksPagesManagementTests(TestCase):
follow=True,
)
self.assertEqual(200, delete_response.status_code)
self.assertFalse(TaskEpic.objects.filter(project=project, name="Phase 1").exists())
self.assertFalse(
TaskEpic.objects.filter(project=project, name="Phase 1").exists()
)
def test_project_page_can_assign_and_clear_task_epic(self):
project = TaskProject.objects.create(user=self.user, name="Roadmap")
@@ -179,9 +183,13 @@ class TasksPagesManagementTests(TestCase):
follow=True,
)
self.assertEqual(200, response.status_code)
self.assertTrue(TaskEpic.objects.filter(project=project, name="Phase 2").exists())
self.assertTrue(
TaskEpic.objects.filter(project=project, name="Phase 2").exists()
)
self.assertTrue(mocked_send.await_count >= 1)
payloads = [str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list]
payloads = [
str(call.kwargs.get("text") or "") for call in mocked_send.await_args_list
]
self.assertTrue(any("whatsapp usage" in row.lower() for row in payloads))
self.assertTrue(any("add task to epic" in row.lower() for row in payloads))
@@ -266,7 +274,9 @@ class TasksPagesManagementTests(TestCase):
reference_code="2",
status_snapshot="open",
)
response = self.client.get(reverse("tasks_project", kwargs={"project_id": str(project.id)}))
response = self.client.get(
reverse("tasks_project", kwargs={"project_id": str(project.id)})
)
self.assertEqual(200, response.status_code)
self.assertContains(
response,
@@ -302,7 +312,9 @@ class TasksPagesManagementTests(TestCase):
payload={"source": "signal", "emoji": "❤️", "reason": "heart_reaction"},
)
response = self.client.get(reverse("tasks_task", kwargs={"task_id": str(task.id)}))
response = self.client.get(
reverse("tasks_task", kwargs={"task_id": str(task.id)})
)
self.assertEqual(200, response.status_code)
self.assertContains(response, "View payload JSON")
self.assertContains(response, "<strong>source</strong>: signal", html=True)

View File

@@ -3,8 +3,8 @@ from __future__ import annotations
from unittest.mock import AsyncMock, patch
from asgiref.sync import async_to_sync
from django.urls import reverse
from django.test import TestCase, override_settings
from django.urls import reverse
from core.models import (
ChatSession,
@@ -12,24 +12,32 @@ from core.models import (
CodexPermissionRequest,
CodexRun,
DerivedTask,
ExternalSyncEvent,
ExternalChatLink,
ExternalSyncEvent,
Message,
Person,
PersonIdentifier,
TaskCompletionPattern,
TaskProviderConfig,
TaskProject,
TaskProviderConfig,
User,
)
from core.tasks.engine import process_inbound_task_intelligence
from core.views.compose import _command_options_for_channel, _toggle_task_announce_for_channel
from core.views.tasks import _apply_safe_defaults_for_user, _ensure_default_completion_patterns
from core.views.compose import (
_command_options_for_channel,
_toggle_task_announce_for_channel,
)
from core.views.tasks import (
_apply_safe_defaults_for_user,
_ensure_default_completion_patterns,
)
class TaskSettingsBackfillTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("defaults-user", "defaults@example.com", "x")
self.user = User.objects.create_user(
"defaults-user", "defaults@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Defaults Person")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
@@ -67,7 +75,9 @@ class TaskSettingsBackfillTests(TestCase):
self.source.refresh_from_db()
self.assertEqual("strict", self.project.settings.get("match_mode"))
self.assertTrue(bool(self.project.settings.get("require_prefix")))
self.assertEqual(["task:", "todo:"], self.project.settings.get("allowed_prefixes"))
self.assertEqual(
["task:", "todo:"], self.project.settings.get("allowed_prefixes")
)
self.assertFalse(bool(self.project.settings.get("announce_task_id")))
self.assertEqual("strict", self.source.settings.get("match_mode"))
self.assertTrue(bool(self.source.settings.get("require_prefix")))
@@ -75,7 +85,9 @@ class TaskSettingsBackfillTests(TestCase):
def test_default_completion_phrases_seeded(self):
_ensure_default_completion_patterns(self.user)
phrases = set(
TaskCompletionPattern.objects.filter(user=self.user).values_list("phrase", flat=True)
TaskCompletionPattern.objects.filter(user=self.user).values_list(
"phrase", flat=True
)
)
self.assertTrue({"done", "completed", "fixed"}.issubset(phrases))
@@ -136,8 +148,12 @@ class TaskAnnounceRuntimeTests(TestCase):
service="whatsapp",
identifier="120363402761690215@g.us",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.project = TaskProject.objects.create(user=self.user, name="Runtime Project")
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
self.project = TaskProject.objects.create(
user=self.user, name="Runtime Project"
)
def _seed_source(self, announce_enabled: bool):
return ChatTaskSource.objects.create(
@@ -167,22 +183,32 @@ class TaskAnnounceRuntimeTests(TestCase):
def test_no_announce_send_when_disabled(self):
self._seed_source(False)
with patch("core.tasks.engine.send_message_raw", new=AsyncMock()) as mocked_send:
async_to_sync(process_inbound_task_intelligence)(self._msg("task: rotate secrets"))
with patch(
"core.tasks.engine.send_message_raw", new=AsyncMock()
) as mocked_send:
async_to_sync(process_inbound_task_intelligence)(
self._msg("task: rotate secrets")
)
self.assertTrue(DerivedTask.objects.exists())
mocked_send.assert_not_awaited()
def test_announce_send_when_enabled(self):
self._seed_source(True)
with patch("core.tasks.engine.send_message_raw", new=AsyncMock(return_value=True)) as mocked_send:
async_to_sync(process_inbound_task_intelligence)(self._msg("task: rotate secrets"))
with patch(
"core.tasks.engine.send_message_raw", new=AsyncMock(return_value=True)
) as mocked_send:
async_to_sync(process_inbound_task_intelligence)(
self._msg("task: rotate secrets")
)
self.assertTrue(DerivedTask.objects.exists())
mocked_send.assert_awaited()
class TaskSettingsViewActionsTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("task-settings-user", "ts@example.com", "x")
self.user = User.objects.create_user(
"task-settings-user", "ts@example.com", "x"
)
self.client.force_login(self.user)
self.project = TaskProject.objects.create(user=self.user, name="Project A")
self.source = ChatTaskSource.objects.create(
@@ -214,7 +240,9 @@ class TaskSettingsViewActionsTests(TestCase):
@override_settings(TASK_DERIVATION_USE_AI=False)
class TaskAutoBootstrapTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("task-auto-user", "task-auto@example.com", "x")
self.user = User.objects.create_user(
"task-auto-user", "task-auto@example.com", "x"
)
self.person = Person.objects.create(user=self.user, name="Bootstrap Chat")
self.identifier = PersonIdentifier.objects.create(
user=self.user,
@@ -222,7 +250,9 @@ class TaskAutoBootstrapTests(TestCase):
service="whatsapp",
identifier="120363402761690215@g.us",
)
self.session = ChatSession.objects.create(user=self.user, identifier=self.identifier)
self.session = ChatSession.objects.create(
user=self.user, identifier=self.identifier
)
def test_task_message_auto_creates_project_and_source(self):
msg = Message.objects.create(
@@ -243,13 +273,17 @@ class TaskAutoBootstrapTests(TestCase):
enabled=True,
).first()
self.assertIsNotNone(source)
self.assertTrue(TaskProject.objects.filter(user=self.user, id=source.project_id).exists())
self.assertTrue(
TaskProject.objects.filter(user=self.user, id=source.project_id).exists()
)
self.assertEqual(1, DerivedTask.objects.filter(user=self.user).count())
class TaskProjectDeleteGuardTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("task-delete-user", "task-delete@example.com", "x")
self.user = User.objects.create_user(
"task-delete-user", "task-delete@example.com", "x"
)
self.client.force_login(self.user)
self.project = TaskProject.objects.create(user=self.user, name="Delete Me")
self.source = ChatTaskSource.objects.create(
@@ -271,7 +305,9 @@ class TaskProjectDeleteGuardTests(TestCase):
follow=True,
)
self.assertEqual(200, response.status_code)
self.assertTrue(TaskProject.objects.filter(id=self.project.id, user=self.user).exists())
self.assertTrue(
TaskProject.objects.filter(id=self.project.id, user=self.user).exists()
)
def test_project_delete_reseeds_default_mapping(self):
response = self.client.post(
@@ -284,7 +320,9 @@ class TaskProjectDeleteGuardTests(TestCase):
follow=True,
)
self.assertEqual(200, response.status_code)
self.assertFalse(TaskProject.objects.filter(id=self.project.id, user=self.user).exists())
self.assertFalse(
TaskProject.objects.filter(id=self.project.id, user=self.user).exists()
)
self.assertTrue(
ChatTaskSource.objects.filter(
user=self.user,
@@ -297,7 +335,9 @@ class TaskProjectDeleteGuardTests(TestCase):
class TaskHubEmptyProjectVisibilityTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("task-hub-user", "task-hub@example.com", "x")
self.user = User.objects.create_user(
"task-hub-user", "task-hub@example.com", "x"
)
self.client.force_login(self.user)
self.empty = TaskProject.objects.create(user=self.user, name="Empty")
self.used = TaskProject.objects.create(user=self.user, name="Used")
@@ -326,7 +366,9 @@ class TaskHubEmptyProjectVisibilityTests(TestCase):
class TaskSettingsExternalChatLinkScopeTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("task-link-user", "task-link@example.com", "x")
self.user = User.objects.create_user(
"task-link-user", "task-link@example.com", "x"
)
self.client.force_login(self.user)
self.group_person = Person.objects.create(user=self.user, name="Scoped Group")
self.group_identifier = PersonIdentifier.objects.create(
@@ -390,7 +432,9 @@ class TaskSettingsExternalChatLinkScopeTests(TestCase):
class CodexSettingsAndSubmitTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("codex-settings-user", "codex-settings@example.com", "x")
self.user = User.objects.create_user(
"codex-settings-user", "codex-settings@example.com", "x"
)
self.client.force_login(self.user)
self.project = TaskProject.objects.create(user=self.user, name="Codex Project")
self.task = DerivedTask.objects.create(
@@ -426,7 +470,9 @@ class CodexSettingsAndSubmitTests(TestCase):
self.assertTrue(cfg.enabled)
self.assertEqual("team-a", str(cfg.settings.get("instance_label") or ""))
self.assertEqual("web", str(cfg.settings.get("approver_service") or ""))
self.assertEqual("approver-chan", str(cfg.settings.get("approver_identifier") or ""))
self.assertEqual(
"approver-chan", str(cfg.settings.get("approver_identifier") or "")
)
def test_task_submit_endpoint_creates_codex_run_and_event(self):
TaskProviderConfig.objects.create(
@@ -444,10 +490,20 @@ class CodexSettingsAndSubmitTests(TestCase):
follow=True,
)
self.assertEqual(200, response.status_code)
run = CodexRun.objects.filter(user=self.user, task=self.task).order_by("-created_at").first()
run = (
CodexRun.objects.filter(user=self.user, task=self.task)
.order_by("-created_at")
.first()
)
self.assertIsNotNone(run)
self.assertEqual("waiting_approval", str(getattr(run, "status", "")))
event = ExternalSyncEvent.objects.filter(user=self.user, task=self.task, provider="codex_cli").order_by("-created_at").first()
event = (
ExternalSyncEvent.objects.filter(
user=self.user, task=self.task, provider="codex_cli"
)
.order_by("-created_at")
.first()
)
self.assertIsNotNone(event)
self.assertEqual("waiting_approval", str(getattr(event, "status", "")))
self.assertTrue(
@@ -474,7 +530,10 @@ class CodexSettingsAndSubmitTests(TestCase):
source_service="web",
source_channel="web-chan-1",
status="waiting_approval",
request_payload={"action": "append_update", "provider_payload": {"task_id": str(self.task.id)}},
request_payload={
"action": "append_update",
"provider_payload": {"task_id": str(self.task.id)},
},
result_payload={},
)
req = CodexPermissionRequest.objects.create(

Some files were not shown because too many files have changed in this diff Show More