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

@@ -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)
if isinstance(result, int)
else int(time.time() * 1000),
"timestamp": (
int(result)
if isinstance(result, int)
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)
if isinstance(result, int)
else int(time.time() * 1000),
"timestamp": (
int(result)
if isinstance(result, int)
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 {},
@@ -2768,9 +2784,9 @@ class WhatsAppClient(ClientBase):
"videoMessage": self._pluck(msg_obj, "videoMessage") or {},
"VideoMessage": self._pluck(msg_obj, "VideoMessage") or {},
"documentMessage": self._pluck(msg_obj, "documentMessage")
or {},
or {},
"DocumentMessage": self._pluck(msg_obj, "DocumentMessage")
or {},
or {},
"ephemeralMessage": self._pluck(msg_obj, "ephemeralMessage")
or {},
"EphemeralMessage": self._pluck(msg_obj, "EphemeralMessage")
@@ -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 (
db_delivered_ts is not None
and projected_delivered_ts is not None
and int(db_delivered_ts) != int(projected_delivered_ts)
)
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 (
db_read_ts is not None
and projected_read_ts is not None
and int(db_read_ts) != int(projected_read_ts)
)
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(
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 ""),
status="waiting_approval",
request_payload=dict(payload or {}),
result_payload=dict(result_payload),
error="",
"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 ""
),
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(
hour=0,
minute=0,
second=0,
microsecond=0,
)
cutoff_ts = int(
(today_start.timestamp() * 1000) - (days * 24 * 60 * 60 * 1000)
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))
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,14 +20,14 @@ 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"),
("INFJ", "INFJ - Advocate"),
("INFP", "INFP - Mediator"),
("ENFJ", "ENFJ - Protagonist"),
("ENFP", "ENFP - Campaigner"), # <3
("ENFP", "ENFP - Campaigner"), # <3
("ISTJ", "ISTJ - Logistician"),
("ISFJ", "ISFJ - Defender"),
("ESTJ", "ESTJ - Executive"),
@@ -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');
addBtn.style.display = 'none';
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,25 +1,25 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Accessibility</h1>
<div class="box">
<h2 class="title is-6">Motion</h2>
<form method="post">
{% csrf_token %}
<div class="field">
<label class="checkbox">
<input type="checkbox" name="disable_animations"{% if accessibility_settings.disable_animations %} checked{% endif %}>
Disable animations
</label>
<p class="help is-size-7 has-text-grey mt-1">
Reduces motion by disabling most transitions and animations across the interface.
</p>
</div>
<button class="button is-link is-small" type="submit">Save</button>
</form>
<section class="section">
<div class="container">
<h1 class="title is-4">Accessibility</h1>
<div class="box">
<h2 class="title is-6">Motion</h2>
<form method="post">
{% csrf_token %}
<div class="field">
<label class="checkbox">
<input type="checkbox" name="disable_animations"{% if accessibility_settings.disable_animations %} checked{% endif %}>
Disable animations
</label>
<p class="help is-size-7 has-text-grey mt-1">
Reduces motion by disabling most transitions and animations across the interface.
</p>
</div>
<button class="button is-link is-small" type="submit">Save</button>
</form>
</div>
</div>
</div>
</section>
</section>
{% endblock %}

View File

@@ -1,301 +1,301 @@
{% extends "base.html" %}
{% block content %}
<div class="level">
<div class="level-left">
<div class="level-item">
<div>
<h1 class="title is-4">Traces</h1>
<p class="subtitle is-6">Tracked model calls and usage metrics for this account.</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
{% if stats.total_runs %}
<span class="tag is-success is-light">Tracking Active</span>
{% else %}
<span class="tag is-warning is-light">No Runs Yet</span>
{% endif %}
</div>
<div class="level">
<div class="level-left">
<div class="level-item">
<div>
<h1 class="title is-4">Traces</h1>
<p class="subtitle is-6">Tracked model calls and usage metrics for this account.</p>
</div>
</div>
<article class="notification is-light">
<p class="is-size-7 has-text-grey-dark">Execution health at a glance</p>
<div class="tags mt-2">
<span class="tag is-light">Total {{ stats.total_runs }}</span>
<span class="tag is-success is-light">OK {{ stats.total_ok }}</span>
<span class="tag is-danger is-light">Failed {{ stats.total_failed }}</span>
<span class="tag is-info is-light">24h {{ stats.last_24h_runs }}</span>
<span class="tag is-warning is-light">24h Failed {{ stats.last_24h_failed }}</span>
<span class="tag is-link is-light">7d {{ stats.last_7d_runs }}</span>
</div>
<p class="is-size-7 has-text-grey-dark mt-3">Success Rate</p>
<progress class="progress is-link is-small" value="{{ stats.success_rate }}" max="100">{{ stats.success_rate }}%</progress>
</article>
<div class="columns is-multiline">
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Reliability</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Runs</th><td>{{ stats.total_runs }}</td></tr>
<tr><th>OK</th><td class="has-text-success">{{ stats.total_ok }}</td></tr>
<tr><th>Failed</th><td class="has-text-danger">{{ stats.total_failed }}</td></tr>
<tr><th>Success Rate</th><td>{{ stats.success_rate }}%</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Throughput</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Runs (24h)</th><td>{{ stats.last_24h_runs }}</td></tr>
<tr><th>Failed (24h)</th><td>{{ stats.last_24h_failed }}</td></tr>
<tr><th>Runs (7d)</th><td>{{ stats.last_7d_runs }}</td></tr>
<tr><th>Avg Duration</th><td>{{ stats.avg_duration_ms }}ms</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Token Proxy (Chars)</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Prompt</th><td>{{ stats.total_prompt_chars }}</td></tr>
<tr><th>Total Response</th><td>{{ stats.total_response_chars }}</td></tr>
<tr><th>Avg Prompt</th><td>{{ stats.avg_prompt_chars }}</td></tr>
<tr><th>Avg Response</th><td>{{ stats.avg_response_chars }}</td></tr>
</tbody>
</table>
</div>
</article>
</div>
</div>
<div class="level-right">
<div class="level-item">
{% if stats.total_runs %}
<span class="tag is-success is-light">Tracking Active</span>
{% else %}
<span class="tag is-warning is-light">No Runs Yet</span>
{% endif %}
</div>
</div>
</div>
<div class="columns">
<div class="column is-6">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">By Operation</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr><th>Operation</th><th>Total</th><th>OK</th><th>Failed</th></tr>
</thead>
<tbody>
{% for row in operation_breakdown %}
<tr>
<td>{{ row.operation|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
</div>
<div class="column is-6">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">By Model</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr><th>Model</th><th>Total</th><th>OK</th><th>Failed</th></tr>
</thead>
<tbody>
{% for row in model_breakdown %}
<tr>
<td>{{ row.model|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
</div>
</div>
<article class="notification is-light">
<p class="is-size-7 has-text-grey-dark">Execution health at a glance</p>
<div class="tags mt-2">
<span class="tag is-light">Total {{ stats.total_runs }}</span>
<span class="tag is-success is-light">OK {{ stats.total_ok }}</span>
<span class="tag is-danger is-light">Failed {{ stats.total_failed }}</span>
<span class="tag is-info is-light">24h {{ stats.last_24h_runs }}</span>
<span class="tag is-warning is-light">24h Failed {{ stats.last_24h_failed }}</span>
<span class="tag is-link is-light">7d {{ stats.last_7d_runs }}</span>
</div>
<p class="is-size-7 has-text-grey-dark mt-3">Success Rate</p>
<progress class="progress is-link is-small" value="{{ stats.success_rate }}" max="100">{{ stats.success_rate }}%</progress>
</article>
<div class="columns is-multiline">
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Recent Runs</p>
<p class="card-header-title is-size-6">Reliability</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Runs</th><td>{{ stats.total_runs }}</td></tr>
<tr><th>OK</th><td class="has-text-success">{{ stats.total_ok }}</td></tr>
<tr><th>Failed</th><td class="has-text-danger">{{ stats.total_failed }}</td></tr>
<tr><th>Success Rate</th><td>{{ stats.success_rate }}%</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Throughput</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Runs (24h)</th><td>{{ stats.last_24h_runs }}</td></tr>
<tr><th>Failed (24h)</th><td>{{ stats.last_24h_failed }}</td></tr>
<tr><th>Runs (7d)</th><td>{{ stats.last_7d_runs }}</td></tr>
<tr><th>Avg Duration</th><td>{{ stats.avg_duration_ms }}ms</td></tr>
</tbody>
</table>
</div>
</article>
</div>
<div class="column is-12-tablet is-4-desktop">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Token Proxy (Chars)</p>
</header>
<div class="card-content">
<table class="table is-fullwidth is-narrow is-size-7">
<tbody>
<tr><th>Total Prompt</th><td>{{ stats.total_prompt_chars }}</td></tr>
<tr><th>Total Response</th><td>{{ stats.total_response_chars }}</td></tr>
<tr><th>Avg Prompt</th><td>{{ stats.avg_prompt_chars }}</td></tr>
<tr><th>Avg Response</th><td>{{ stats.avg_response_chars }}</td></tr>
</tbody>
</table>
</div>
</article>
</div>
</div>
<div class="columns">
<div class="column is-6">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">By Operation</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr>
<th></th>
<th>Started</th>
<th>Status</th>
<th>Operation</th>
<th>Model</th>
<th>Messages</th>
<th>Prompt</th>
<th>Response</th>
<th>Duration</th>
<th>Error</th>
</tr>
<tr><th>Operation</th><th>Total</th><th>OK</th><th>Failed</th></tr>
</thead>
<tbody>
{% for run in runs %}
{% for row in operation_breakdown %}
<tr>
<td>
<button
class="button is-small is-light trace-run-expand"
type="button"
data-detail-row="trace-run-detail-{{ run.id }}"
data-detail-content="trace-run-detail-content-{{ run.id }}"
data-expanded-label="Hide"
data-collapsed-label="Show"
hx-get="{% url 'ai_execution_run_detail' run_id=run.id %}"
hx-target="#trace-run-detail-content-{{ run.id }}"
hx-swap="innerHTML"
hx-trigger="click once"
>
Show
</button>
</td>
<td>{{ run.started_at }}</td>
<td>
{% if run.status == "ok" %}
<span class="tag is-success is-light">ok</span>
{% elif run.status == "failed" %}
<span class="tag is-danger is-light">failed</span>
{% else %}
<span class="tag is-light">{{ run.status }}</span>
{% endif %}
</td>
<td>{{ run.operation|default:"-" }}</td>
<td>{{ run.model|default:"-" }}</td>
<td>{{ run.message_count }}</td>
<td>{{ run.prompt_chars }}</td>
<td>{{ run.response_chars }}</td>
<td>{% if run.duration_ms %}{{ run.duration_ms }}ms{% else %}-{% endif %}</td>
<td>
{% if run.error %}
<span title="{{ run.error }}">{{ run.error|truncatechars:120 }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
<tr id="trace-run-detail-{{ run.id }}" class="is-hidden">
<td colspan="10">
<div id="trace-run-detail-content-{{ run.id }}" class="trace-run-detail-shell is-size-7 has-text-grey">
Click Show to load run details.
</div>
</td>
<td>{{ row.operation|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="10">No runs yet.</td></tr>
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
<script>
(function () {
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
button.addEventListener("click", function () {
const rowId = String(button.getAttribute("data-detail-row") || "");
const row = rowId ? document.getElementById(rowId) : null;
if (!row) {
</div>
<div class="column is-6">
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">By Model</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr><th>Model</th><th>Total</th><th>OK</th><th>Failed</th></tr>
</thead>
<tbody>
{% for row in model_breakdown %}
<tr>
<td>{{ row.model|default:"(none)" }}</td>
<td>{{ row.total }}</td>
<td class="has-text-success">{{ row.ok }}</td>
<td class="has-text-danger">{{ row.failed }}</td>
</tr>
{% empty %}
<tr><td colspan="4">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
</div>
</div>
<article class="card">
<header class="card-header">
<p class="card-header-title is-size-6">Recent Runs</p>
</header>
<div class="card-content">
<div class="table-container">
<table class="table is-fullwidth is-size-7 is-striped is-hoverable">
<thead>
<tr>
<th></th>
<th>Started</th>
<th>Status</th>
<th>Operation</th>
<th>Model</th>
<th>Messages</th>
<th>Prompt</th>
<th>Response</th>
<th>Duration</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for run in runs %}
<tr>
<td>
<button
class="button is-small is-light trace-run-expand"
type="button"
data-detail-row="trace-run-detail-{{ run.id }}"
data-detail-content="trace-run-detail-content-{{ run.id }}"
data-expanded-label="Hide"
data-collapsed-label="Show"
hx-get="{% url 'ai_execution_run_detail' run_id=run.id %}"
hx-target="#trace-run-detail-content-{{ run.id }}"
hx-swap="innerHTML"
hx-trigger="click once"
>
Show
</button>
</td>
<td>{{ run.started_at }}</td>
<td>
{% if run.status == "ok" %}
<span class="tag is-success is-light">ok</span>
{% elif run.status == "failed" %}
<span class="tag is-danger is-light">failed</span>
{% else %}
<span class="tag is-light">{{ run.status }}</span>
{% endif %}
</td>
<td>{{ run.operation|default:"-" }}</td>
<td>{{ run.model|default:"-" }}</td>
<td>{{ run.message_count }}</td>
<td>{{ run.prompt_chars }}</td>
<td>{{ run.response_chars }}</td>
<td>{% if run.duration_ms %}{{ run.duration_ms }}ms{% else %}-{% endif %}</td>
<td>
{% if run.error %}
<span title="{{ run.error }}">{{ run.error|truncatechars:120 }}</span>
{% else %}
-
{% endif %}
</td>
</tr>
<tr id="trace-run-detail-{{ run.id }}" class="is-hidden">
<td colspan="10">
<div id="trace-run-detail-content-{{ run.id }}" class="trace-run-detail-shell is-size-7 has-text-grey">
Click Show to load run details.
</div>
</td>
</tr>
{% empty %}
<tr><td colspan="10">No runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</article>
<script>
(function () {
document.querySelectorAll(".trace-run-expand").forEach(function (button) {
button.addEventListener("click", function () {
const rowId = String(button.getAttribute("data-detail-row") || "");
const row = rowId ? document.getElementById(rowId) : null;
if (!row) {
return;
}
const isHidden = row.classList.contains("is-hidden");
row.classList.toggle("is-hidden", !isHidden);
button.textContent = isHidden
? String(button.getAttribute("data-expanded-label") || "Hide")
: String(button.getAttribute("data-collapsed-label") || "Show");
});
});
document.addEventListener("click", function (event) {
const trigger = event.target.closest(".trace-run-tab-trigger");
if (!trigger) {
return;
}
const isHidden = row.classList.contains("is-hidden");
row.classList.toggle("is-hidden", !isHidden);
button.textContent = isHidden
? String(button.getAttribute("data-expanded-label") || "Hide")
: String(button.getAttribute("data-collapsed-label") || "Show");
});
});
document.addEventListener("click", function (event) {
const trigger = event.target.closest(".trace-run-tab-trigger");
if (!trigger) {
return;
}
event.preventDefault();
const shell = trigger.closest(".trace-run-detail-tabs");
if (!shell) {
return;
}
const targetName = String(trigger.getAttribute("data-tab-target") || "");
if (!targetName) {
return;
}
shell.querySelectorAll(".trace-run-tab-trigger").forEach(function (item) {
item.parentElement.classList.toggle("is-active", item === trigger);
});
shell.querySelectorAll(".trace-run-tab-panel").forEach(function (panel) {
const isActive = panel.getAttribute("data-tab-panel") === targetName;
panel.classList.toggle("is-hidden", !isActive);
});
const lazyUrl = String(trigger.getAttribute("data-lazy-url") || "");
if (!lazyUrl) {
return;
}
const panel = shell.querySelector(
'.trace-run-tab-panel[data-tab-panel="' + targetName + '"]'
);
if (!panel || panel.getAttribute("data-loaded") === "1") {
return;
}
panel.setAttribute("data-loaded", "1");
panel.classList.add("is-loading");
fetch(lazyUrl, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("tab load failed");
}
return response.text();
})
.then(function (html) {
panel.innerHTML = html;
})
.catch(function () {
panel.innerHTML =
'<p class="has-text-danger">Unable to load this tab.</p>';
})
.finally(function () {
panel.classList.remove("is-loading");
event.preventDefault();
const shell = trigger.closest(".trace-run-detail-tabs");
if (!shell) {
return;
}
const targetName = String(trigger.getAttribute("data-tab-target") || "");
if (!targetName) {
return;
}
shell.querySelectorAll(".trace-run-tab-trigger").forEach(function (item) {
item.parentElement.classList.toggle("is-active", item === trigger);
});
});
})();
</script>
shell.querySelectorAll(".trace-run-tab-panel").forEach(function (panel) {
const isActive = panel.getAttribute("data-tab-panel") === targetName;
panel.classList.toggle("is-hidden", !isActive);
});
const lazyUrl = String(trigger.getAttribute("data-lazy-url") || "");
if (!lazyUrl) {
return;
}
const panel = shell.querySelector(
'.trace-run-tab-panel[data-tab-panel="' + targetName + '"]'
);
if (!panel || panel.getAttribute("data-loaded") === "1") {
return;
}
panel.setAttribute("data-loaded", "1");
panel.classList.add("is-loading");
fetch(lazyUrl, { credentials: "same-origin" })
.then(function (response) {
if (!response.ok) {
throw new Error("tab load failed");
}
return response.text();
})
.then(function (html) {
panel.innerHTML = html;
})
.catch(function () {
panel.innerHTML =
'<p class="has-text-danger">Unable to load this tab.</p>';
})
.finally(function () {
panel.classList.remove("is-loading");
});
});
})();
</script>
{% endblock %}

View File

@@ -1,71 +1,71 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Availability Settings</h1>
<form method="post" class="box">
{% csrf_token %}
<div class="columns is-multiline">
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="enabled" {% if settings_row.enabled %}checked{% endif %}> Enabled</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_chat" {% if settings_row.show_in_chat %}checked{% endif %}> Show In Chat</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_groups" {% if settings_row.show_in_groups %}checked{% endif %}> Show In Groups</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="inference_enabled" {% if settings_row.inference_enabled %}checked{% endif %}> Inference Enabled</label></div>
<div class="column is-3">
<label class="label is-size-7">Retention Days</label>
<input class="input is-small" type="number" min="1" name="retention_days" value="{{ settings_row.retention_days }}">
<section class="section">
<div class="container">
<h1 class="title is-4">Availability Settings</h1>
<form method="post" class="box">
{% csrf_token %}
<div class="columns is-multiline">
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="enabled" {% if settings_row.enabled %}checked{% endif %}> Enabled</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_chat" {% if settings_row.show_in_chat %}checked{% endif %}> Show In Chat</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="show_in_groups" {% if settings_row.show_in_groups %}checked{% endif %}> Show In Groups</label></div>
<div class="column is-3"><label class="checkbox"><input type="checkbox" name="inference_enabled" {% if settings_row.inference_enabled %}checked{% endif %}> Inference Enabled</label></div>
<div class="column is-3">
<label class="label is-size-7">Retention Days</label>
<input class="input is-small" type="number" min="1" name="retention_days" value="{{ settings_row.retention_days }}">
</div>
<div class="column is-3">
<label class="label is-size-7">Fade Threshold (seconds)</label>
<input class="input is-small" type="number" min="30" name="fade_threshold_seconds" value="{{ settings_row.fade_threshold_seconds }}">
</div>
</div>
<div class="column is-3">
<label class="label is-size-7">Fade Threshold (seconds)</label>
<input class="input is-small" type="number" min="30" name="fade_threshold_seconds" value="{{ settings_row.fade_threshold_seconds }}">
</div>
</div>
<button class="button is-link is-small" type="submit">Save</button>
</form>
<button class="button is-link is-small" type="submit">Save</button>
</form>
<div class="box">
<h2 class="title is-6">Availability Event Statistics Per Contact</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr>
<th>Contact</th>
<th>Service</th>
<th>Total</th>
<th>Available</th>
<th>Fading</th>
<th>Unavailable</th>
<th>Unknown</th>
<th>Native</th>
<th>Read</th>
<th>Typing</th>
<th>Msg Activity</th>
<th>Timeout</th>
<th>Last Event TS</th>
</tr>
</thead>
<tbody>
{% for row in contact_stats %}
<div class="box">
<h2 class="title is-6">Availability Event Statistics Per Contact</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead>
<tr>
<td>{{ row.person__name }}</td>
<td>{{ row.service }}</td>
<td>{{ row.total_events }}</td>
<td>{{ row.available_events }}</td>
<td>{{ row.fading_events }}</td>
<td>{{ row.unavailable_events }}</td>
<td>{{ row.unknown_events }}</td>
<td>{{ row.native_presence_events }}</td>
<td>{{ row.read_receipt_events }}</td>
<td>{{ row.typing_events }}</td>
<td>{{ row.message_activity_events }}</td>
<td>{{ row.inferred_timeout_events }}</td>
<td>{{ row.last_event_ts }}</td>
<th>Contact</th>
<th>Service</th>
<th>Total</th>
<th>Available</th>
<th>Fading</th>
<th>Unavailable</th>
<th>Unknown</th>
<th>Native</th>
<th>Read</th>
<th>Typing</th>
<th>Msg Activity</th>
<th>Timeout</th>
<th>Last Event TS</th>
</tr>
{% empty %}
<tr><td colspan="13">No availability events found.</td></tr>
{% endfor %}
</tbody>
</table>
</thead>
<tbody>
{% for row in contact_stats %}
<tr>
<td>{{ row.person__name }}</td>
<td>{{ row.service }}</td>
<td>{{ row.total_events }}</td>
<td>{{ row.available_events }}</td>
<td>{{ row.fading_events }}</td>
<td>{{ row.unavailable_events }}</td>
<td>{{ row.unknown_events }}</td>
<td>{{ row.native_presence_events }}</td>
<td>{{ row.read_receipt_events }}</td>
<td>{{ row.typing_events }}</td>
<td>{{ row.message_activity_events }}</td>
<td>{{ row.inferred_timeout_events }}</td>
<td>{{ row.last_event_ts }}</td>
</tr>
{% empty %}
<tr><td colspan="13">No availability events found.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</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,144 +1,144 @@
{% extends "base.html" %}
{% block content %}
<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>
<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>
<article class="box">
<div class="codex-inline-stats">
<span><strong>Provider</strong> codex_cli</span>
<span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span>
<span><strong>Pending</strong> {{ queue_counts.pending }}</span>
<span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span>
</div>
{% if health and health.error %}
<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>
</article>
<article class="box">
<div class="codex-inline-stats">
<span><strong>Provider</strong> codex_cli</span>
<span><strong>Health</strong> <span class="{% if health and health.ok %}has-text-success{% else %}has-text-danger{% endif %}">{% if health and health.ok %}online{% else %}offline{% endif %}</span></span>
<span><strong>Pending</strong> {{ queue_counts.pending }}</span>
<span><strong>Waiting Approval</strong> {{ queue_counts.waiting_approval }}</span>
</div>
{% if health and health.error %}
<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 Automation</a>.</p>
</article>
<article class="box">
<h2 class="title is-6">Run Filters</h2>
<form method="get">
<div class="columns is-multiline">
<div class="column is-2">
<label class="label is-size-7">Status</label>
<input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/...">
</div>
<div class="column is-2">
<label class="label is-size-7">Service</label>
<input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal">
</div>
<div class="column is-3">
<label class="label is-size-7">Channel</label>
<input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier">
</div>
<div class="column is-3">
<label class="label is-size-7">Project</label>
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">All</option>
{% for row in projects %}
<option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option>
{% endfor %}
</select>
<article class="box">
<h2 class="title is-6">Run Filters</h2>
<form method="get">
<div class="columns is-multiline">
<div class="column is-2">
<label class="label is-size-7">Status</label>
<input class="input is-small" name="status" value="{{ filters.status }}" placeholder="ok/failed/...">
</div>
<div class="column is-2">
<label class="label is-size-7">Service</label>
<input class="input is-small" name="service" value="{{ filters.service }}" placeholder="signal">
</div>
<div class="column is-3">
<label class="label is-size-7">Channel</label>
<input class="input is-small" name="channel" value="{{ filters.channel }}" placeholder="identifier">
</div>
<div class="column is-3">
<label class="label is-size-7">Project</label>
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">All</option>
{% for row in projects %}
<option value="{{ row.id }}" {% if filters.project == row.id|stringformat:"s" %}selected{% endif %}>{{ row.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Date From</label>
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}">
</div>
</div>
<div class="column is-2">
<label class="label is-size-7">Date From</label>
<input class="input is-small" type="date" name="date_from" value="{{ filters.date_from }}">
</div>
</div>
<button class="button is-small is-link is-light" type="submit">Apply</button>
</form>
</article>
<button class="button is-small is-link is-light" type="submit">Apply</button>
</form>
</article>
<article class="box">
<h2 class="title is-6">Runs</h2>
<table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead>
<tbody>
{% for run in runs %}
<tr>
<td>{{ run.created_at }}</td>
<td>{{ run.status }}</td>
<td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td>
<td>{{ run.project.name|default:"-" }}</td>
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>{{ run.result_payload.summary|default:"-" }}</td>
<td>{{ run.result_payload.files_modified_count|default:"0" }}</td>
<td>
<details>
<summary>Details</summary>
<p><strong>Request</strong></p>
<article class="box">
<h2 class="title is-6">Runs</h2>
<table class="table is-fullwidth is-size-7 is-striped">
<thead><tr><th>When</th><th>Status</th><th>Service/Channel</th><th>Project</th><th>Task</th><th>Summary</th><th>Files</th><th>Links</th></tr></thead>
<tbody>
{% for run in runs %}
<tr>
<td>{{ run.created_at }}</td>
<td>{{ run.status }}</td>
<td>{{ run.source_service }} · <code>{{ run.source_channel }}</code></td>
<td>{{ run.project.name|default:"-" }}</td>
<td>{% if run.task %}<a href="{% url 'tasks_task' task_id=run.task.id %}">#{{ run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>{{ run.result_payload.summary|default:"-" }}</td>
<td>{{ run.result_payload.files_modified_count|default:"0" }}</td>
<td>
<details>
<summary>Details</summary>
<p><strong>Request</strong></p>
<pre>{{ run.request_payload }}</pre>
<p><strong>Result</strong></p>
<p><strong>Result</strong></p>
<pre>{{ run.result_payload }}</pre>
<p><strong>Error</strong> {{ run.error|default:"-" }}</p>
</details>
</td>
</tr>
{% empty %}
<tr><td colspan="8">No runs.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<p><strong>Error</strong> {{ run.error|default:"-" }}</p>
</details>
</td>
</tr>
{% empty %}
<tr><td colspan="8">No runs.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Permission 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>
{% for row in permission_requests %}
<tr>
<td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td>
<td><pre>{{ row.requested_permissions }}</pre></td>
<td><code>{{ row.codex_run_id }}</code></td>
<td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>
{% if row.status == 'pending' %}
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="approve">
<button class="button is-small is-success is-light" type="submit">Approve</button>
</form>
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="deny">
<button class="button is-small is-danger is-light" type="submit">Deny</button>
</form>
{% else %}
-
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8">No permission requests.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
<style>
.codex-inline-stats {
display: flex;
gap: 0.95rem;
flex-wrap: wrap;
font-size: 0.92rem;
margin-bottom: 0.25rem;
}
.codex-inline-stats span {
white-space: nowrap;
}
</style>
<article class="box">
<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>
{% for row in permission_requests %}
<tr>
<td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td>
<td><pre>{{ row.requested_permissions }}</pre></td>
<td><code>{{ row.codex_run_id }}</code></td>
<td>{% if row.codex_run.task %}<a href="{% url 'tasks_task' task_id=row.codex_run.task.id %}">#{{ row.codex_run.task.reference_code }}</a>{% else %}-{% endif %}</td>
<td>
{% if row.status == 'pending' %}
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="approve">
<button class="button is-small is-success is-light" type="submit">Approve</button>
</form>
<form method="post" action="{% url 'codex_approval' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="request_id" value="{{ row.id }}">
<input type="hidden" name="decision" value="deny">
<button class="button is-small is-danger is-light" type="submit">Deny</button>
</form>
{% else %}
-
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="8">No permission requests.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
<style>
.codex-inline-stats {
display: flex;
gap: 0.95rem;
flex-wrap: wrap;
font-size: 0.92rem;
margin-bottom: 0.25rem;
}
.codex-inline-stats span {
white-space: nowrap;
}
</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>

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,22 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">{{ category_title }}</h1>
<p class="subtitle is-6">{{ category_description }}</p>
<div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul>
{% for tab in category_tabs %}
<li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a>
</li>
{% endfor %}
</ul>
<section class="section">
<div class="container">
<h1 class="title is-4">{{ category_title }}</h1>
<p class="subtitle is-6">{{ category_description }}</p>
<div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul>
{% for tab in category_tabs %}
<li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a>
</li>
{% endfor %}
</ul>
</div>
<div class="box">
<p class="is-size-7 has-text-grey">Choose a tab above to open settings in this category.</p>
</div>
</div>
<div class="box">
<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,127 +1,127 @@
{% extends "base.html" %}
{% block content %}
<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;">
Created by {{ task.creator_label|default:"Unknown" }}
{% if task.origin_message_id %}
· Source message <code>{{ task.origin_message_id }}</code>
{% endif %}
</p>
<div class="buttons">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ task.id }}">
<input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}">
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
</form>
</div>
<article class="box">
<h2 class="title is-6">Events</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Type</th><th>Actor</th><th>Payload</th></tr></thead>
<tbody>
{% for row in events %}
<tr>
<td>{{ row.created_at }}</td>
<td>{{ row.event_type }}</td>
<td>
{{ row.actor_display|default:"Unknown" }}
{% if row.actor_identifier and row.actor_identifier != row.actor_display %}
<div class="has-text-grey"><code>{{ row.actor_identifier }}</code></div>
{% endif %}
</td>
<td>
{% if row.payload_view.summary_items %}
<div class="tags" style="margin-bottom: 0.35rem;">
{% for item in row.payload_view.summary_items %}
<span class="tag task-ui-badge"><strong>{{ item.0 }}</strong>: {{ item.1 }}</span>
{% endfor %}
</div>
{% endif %}
<details>
<summary class="is-size-7 has-text-link" style="cursor:pointer;">View payload JSON</summary>
<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;">
Created by {{ task.creator_label|default:"Unknown" }}
{% if task.origin_message_id %}
· Source message <code>{{ task.origin_message_id }}</code>
{% endif %}
</p>
<div class="buttons">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ task.id }}">
<input type="hidden" name="next" value="{% url 'tasks_task' task_id=task.id %}">
<button class="button is-small is-link is-light" type="submit">Send to Codex</button>
</form>
</div>
<article class="box">
<h2 class="title is-6">Events</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Type</th><th>Actor</th><th>Payload</th></tr></thead>
<tbody>
{% for row in events %}
<tr>
<td>{{ row.created_at }}</td>
<td>{{ row.event_type }}</td>
<td>
{{ row.actor_display|default:"Unknown" }}
{% if row.actor_identifier and row.actor_identifier != row.actor_display %}
<div class="has-text-grey"><code>{{ row.actor_identifier }}</code></div>
{% endif %}
</td>
<td>
{% if row.payload_view.summary_items %}
<div class="tags" style="margin-bottom: 0.35rem;">
{% for item in row.payload_view.summary_items %}
<span class="tag task-ui-badge"><strong>{{ item.0 }}</strong>: {{ item.1 }}</span>
{% endfor %}
</div>
{% endif %}
<details>
<summary class="is-size-7 has-text-link" style="cursor:pointer;">View payload JSON</summary>
<pre class="task-event-payload">{{ row.payload_view.pretty_text }}</pre>
</details>
</td>
</tr>
{% empty %}
<tr><td colspan="4">No events.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">External Sync</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Provider</th><th>Status</th><th>Error</th></tr></thead>
<tbody>
{% for row in sync_events %}
<tr><td>{{ row.updated_at }}</td><td>{{ row.provider }}</td><td>{{ row.status }}</td><td>{{ row.error }}</td></tr>
{% empty %}
<tr><td colspan="4">No sync events.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Codex Runs</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead>
<tbody>
{% for row in codex_runs %}
<tr>
<td>{{ row.updated_at }}</td>
<td>{{ row.status }}</td>
<td>{{ row.result_payload.summary|default:"-" }}</td>
<td>{{ row.result_payload.files_modified_count|default:"0" }}</td>
<td>{{ row.error|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No Codex runs.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Permission Requests</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead>
<tbody>
{% for row in permission_requests %}
<tr>
<td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td>
<td>{{ row.resolved_at|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No permission requests.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div></section>
<style>
.task-event-payload {
margin-top: 0.35rem;
padding: 0.6rem;
border-radius: 8px;
border: 1px solid rgba(127, 127, 127, 0.25);
background: rgba(245, 245, 245, 0.75);
color: #1f1f1f;
max-width: 72ch;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
[data-theme="dark"] .task-event-payload {
background: rgba(35, 35, 35, 0.75);
color: #f5f5f5;
border-color: rgba(200, 200, 200, 0.35);
}
</style>
</details>
</td>
</tr>
{% empty %}
<tr><td colspan="4">No events.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">External Sync</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Provider</th><th>Status</th><th>Error</th></tr></thead>
<tbody>
{% for row in sync_events %}
<tr><td>{{ row.updated_at }}</td><td>{{ row.provider }}</td><td>{{ row.status }}</td><td>{{ row.error }}</td></tr>
{% empty %}
<tr><td colspan="4">No sync events.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Codex Runs</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Status</th><th>Summary</th><th>Files</th><th>Error</th></tr></thead>
<tbody>
{% for row in codex_runs %}
<tr>
<td>{{ row.updated_at }}</td>
<td>{{ row.status }}</td>
<td>{{ row.result_payload.summary|default:"-" }}</td>
<td>{{ row.result_payload.files_modified_count|default:"0" }}</td>
<td>{{ row.error|default:"" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No Codex runs.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Permission Requests</h2>
<table class="table is-fullwidth is-size-7">
<thead><tr><th>When</th><th>Approval Key</th><th>Status</th><th>Summary</th><th>Resolved</th></tr></thead>
<tbody>
{% for row in permission_requests %}
<tr>
<td>{{ row.requested_at }}</td>
<td><code>{{ row.approval_key }}</code></td>
<td>{{ row.status }}</td>
<td>{{ row.summary|default:"-" }}</td>
<td>{{ row.resolved_at|default:"-" }}</td>
</tr>
{% empty %}
<tr><td colspan="5">No permission requests.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div></section>
<style>
.task-event-payload {
margin-top: 0.35rem;
padding: 0.6rem;
border-radius: 8px;
border: 1px solid rgba(127, 127, 127, 0.25);
background: rgba(245, 245, 245, 0.75);
color: #1f1f1f;
max-width: 72ch;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
[data-theme="dark"] .task-event-payload {
background: rgba(35, 35, 35, 0.75);
color: #f5f5f5;
border-color: rgba(200, 200, 200, 0.35);
}
</style>
{% endblock %}

View File

@@ -1,22 +1,22 @@
{% extends "base.html" %}
{% block content %}
<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">
<ul class="is-size-7">
{% for row in tasks %}
<li>
<a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a>
<span class="has-text-grey">· by {{ row.creator_label|default:"Unknown" }}</span>
{% if row.creator_identifier %}
<code>{{ row.creator_identifier }}</code>
{% endif %}
</li>
{% empty %}
<li>No tasks.</li>
{% endfor %}
</ul>
</article>
</div></section>
<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">
<ul class="is-size-7">
{% for row in tasks %}
<li>
<a href="{% url 'tasks_task' task_id=row.id %}">#{{ row.reference_code }} {{ row.title }}</a>
<span class="has-text-grey">· by {{ row.creator_label|default:"Unknown" }}</span>
{% if row.creator_identifier %}
<code>{{ row.creator_identifier }}</code>
{% endif %}
</li>
{% empty %}
<li>No tasks.</li>
{% endfor %}
</ul>
</article>
</div></section>
{% endblock %}

View File

@@ -1,84 +1,84 @@
{% extends "base.html" %}
{% block content %}
<section class="section"><div class="container">
<h1 class="title is-4">Group Tasks: {{ 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>
{% if primary_project %}
<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>
{% if primary_project %}
<form method="post" style="margin-bottom: 0.7rem;">
{% csrf_token %}
<input type="hidden" name="action" value="group_project_rename">
<div class="columns is-multiline">
<div class="column is-7">
<label class="label is-size-7">Rename Current Chat Project</label>
<input class="input is-small" name="project_name" value="{{ primary_project.name }}">
</div>
<div class="column is-5" style="display:flex; align-items:flex-end;">
<button class="button is-small is-light" type="submit">Rename</button>
</div>
</div>
</form>
{% endif %}
<form method="post" style="margin-bottom: 0.7rem;">
{% csrf_token %}
<input type="hidden" name="action" value="group_project_rename">
<input type="hidden" name="action" value="group_project_create">
<div class="columns is-multiline">
<div class="column is-7">
<label class="label is-size-7">Rename Current Chat Project</label>
<input class="input is-small" name="project_name" value="{{ primary_project.name }}">
<div class="column is-5">
<label class="label is-size-7">Project Name</label>
<input class="input is-small" name="project_name" placeholder="Project name">
</div>
<div class="column is-5" style="display:flex; align-items:flex-end;">
<button class="button is-small is-light" type="submit">Rename</button>
<div class="column is-5">
<label class="label is-size-7">Initial Epic (optional)</label>
<input class="input is-small" name="epic_name" placeholder="Epic name">
</div>
<div class="column is-2" style="display:flex; align-items:flex-end;">
<button class="button is-small is-link is-light" type="submit">Create + Map</button>
</div>
</div>
</form>
{% endif %}
<form method="post" style="margin-bottom: 0.7rem;">
{% csrf_token %}
<input type="hidden" name="action" value="group_project_create">
<div class="columns is-multiline">
<div class="column is-5">
<label class="label is-size-7">Project Name</label>
<input class="input is-small" name="project_name" placeholder="Project name">
</div>
<div class="column is-5">
<label class="label is-size-7">Initial Epic (optional)</label>
<input class="input is-small" name="epic_name" placeholder="Epic name">
</div>
<div class="column is-2" style="display:flex; align-items:flex-end;">
<button class="button is-small is-link is-light" type="submit">Create + Map</button>
</div>
</div>
</form>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="group_map_existing_project">
<div class="columns is-multiline">
<div class="column is-9">
<label class="label is-size-7">Existing Project</label>
<div class="select is-small is-fullwidth">
<select name="project_id">
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% empty %}
<option value="">No projects available</option>
{% endfor %}
</select>
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="group_map_existing_project">
<div class="columns is-multiline">
<div class="column is-9">
<label class="label is-size-7">Existing Project</label>
<div class="select is-small is-fullwidth">
<select name="project_id">
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% empty %}
<option value="">No projects available</option>
{% endfor %}
</select>
</div>
</div>
<div class="column is-3" style="display:flex; align-items:flex-end;">
<button class="button is-small is-light" type="submit">Map Existing</button>
</div>
</div>
<div class="column is-3" style="display:flex; align-items:flex-end;">
<button class="button is-small is-light" type="submit">Map Existing</button>
</div>
</div>
</form>
</article>
{% if not tasks %}
<article class="box">
<h2 class="title is-6">No Tasks Yet</h2>
<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>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>
</ol>
</div>
</form>
</article>
{% endif %}
<article class="box">
<h2 class="title is-6">Mappings</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Epic</th><th>Channel</th><th>Enabled</th><th></th></tr></thead>
<tbody>
{% for row in mappings %}
{% if not tasks %}
<article class="box">
<h2 class="title is-6">No Tasks Yet</h2>
<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 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>
</ol>
</div>
</article>
{% endif %}
<article class="box">
<h2 class="title is-6">Mappings</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Epic</th><th>Channel</th><th>Enabled</th><th></th></tr></thead>
<tbody>
{% for row in mappings %}
<tr>
<td>{{ row.project.name }}</td>
<td>{% if row.epic %}{{ row.epic.name }}{% else %}-{% endif %}</td>
@@ -88,36 +88,36 @@
<td>{{ row.enabled }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_project' project_id=row.project_id %}">Open Project</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No mappings for this group.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %}
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="6">No tasks yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div></section>
{% empty %}
<tr><td colspan="5">No mappings for this group.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th></th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %}
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="6">No tasks yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div></section>
{% endblock %}

View File

@@ -1,204 +1,204 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Tasks</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>
</div>
<div class="columns is-variable is-5">
<div class="column is-4">
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6" style="margin: 0;">Projects</h2>
<span class="tag task-ui-badge">{{ projects|length }}</span>
</div>
<p class="help" style="margin-bottom: 0.45rem;">Projects are created automatically from chat usage. Use this panel for manual cleanup and mapping.</p>
<div class="buttons" style="margin-bottom:0.55rem;">
{% if show_empty_projects %}
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Hide empty projects</a>
{% else %}
<a class="button is-small is-light" href="{% url 'tasks_hub' %}?show_empty=1">Show empty projects</a>
{% endif %}
</div>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="project_create">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New project name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add</button>
</div>
<section class="section">
<div class="container">
<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 Automation</a>
</div>
<div class="columns is-variable is-5">
<div class="column is-4">
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6" style="margin: 0;">Projects</h2>
<span class="tag task-ui-badge">{{ projects|length }}</span>
</div>
</form>
{% if scope.person %}
<article class="message is-light" style="margin-bottom: 0.75rem;">
<div class="message-body is-size-7">
Setup scope: <strong>{{ scope.person.name }}</strong>
{% if scope.service and scope.identifier %}
· {{ scope.service }} · {{ scope.identifier }}
{% endif %}
<p class="help" style="margin-bottom: 0.45rem;">Projects are created automatically from chat usage. Use this panel for manual cleanup and mapping.</p>
<div class="buttons" style="margin-bottom:0.55rem;">
{% if show_empty_projects %}
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Hide empty projects</a>
{% else %}
<a class="button is-small is-light" href="{% url 'tasks_hub' %}?show_empty=1">Show empty projects</a>
{% endif %}
</div>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="project_create">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New project name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add</button>
</div>
</div>
</article>
<div style="margin-bottom: 0.75rem;">
<label class="label is-size-7">Map Linked Identifiers To Project</label>
<form method="get">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">Select project</option>
{% for project in project_choices %}
<option value="{{ project.id }}" {% if selected_project and selected_project.id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</form>
{% if scope.person %}
<article class="message is-light" style="margin-bottom: 0.75rem;">
<div class="message-body is-size-7">
Setup scope: <strong>{{ scope.person.name }}</strong>
{% if scope.service and scope.identifier %}
· {{ scope.service }} · {{ scope.identifier }}
{% endif %}
</div>
</article>
<div style="margin-bottom: 0.75rem;">
<label class="label is-size-7">Map Linked Identifiers To Project</label>
<form method="get">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<div class="field has-addons">
<div class="control is-expanded">
<div class="select is-small is-fullwidth">
<select name="project">
<option value="">Select project</option>
{% for project in project_choices %}
<option value="{{ project.id }}" {% if selected_project and selected_project.id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-light" type="submit">Select</button>
</div>
</div>
<div class="control">
<button class="button is-small is-light" type="submit">Select</button>
</div>
</div>
</form>
</div>
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
<tbody>
{% for row in person_identifier_rows %}
<tr>
<td><code>{{ row.identifier }}</code></td>
<td>{{ row.service }}</td>
<td class="has-text-right">
{% if selected_project %}
{% if row.mapped %}
<span class="tag task-ui-badge">Linked</span>
</form>
</div>
<table class="table is-fullwidth is-striped is-size-7" style="margin-bottom:0.9rem;">
<thead><tr><th>Identifier</th><th>Service</th><th></th></tr></thead>
<tbody>
{% for row in person_identifier_rows %}
<tr>
<td><code>{{ row.identifier }}</code></td>
<td>{{ row.service }}</td>
<td class="has-text-right">
{% if selected_project %}
{% if row.mapped %}
<span class="tag task-ui-badge">Linked</span>
{% else %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="project_map_identifier">
<input type="hidden" name="project_id" value="{{ selected_project.id }}">
<input type="hidden" name="person_identifier_id" value="{{ row.id }}">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<button class="button is-small is-light" type="submit">Link</button>
</form>
{% endif %}
{% else %}
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="project_map_identifier">
<input type="hidden" name="project_id" value="{{ selected_project.id }}">
<input type="hidden" name="person_identifier_id" value="{{ row.id }}">
<input type="hidden" name="person" value="{{ scope.person_id }}">
<input type="hidden" name="service" value="{{ scope.service }}">
<input type="hidden" name="identifier" value="{{ scope.identifier }}">
<button class="button is-small is-light" type="submit">Link</button>
</form>
<span class="has-text-grey">Select project</span>
{% endif %}
{% else %}
<span class="has-text-grey">Select project</span>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No linked identifiers for this person yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="help" style="margin-bottom: 0.75rem;">
Open this page from Compose to map a persons linked identifiers in one click.
</p>
{% endif %}
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Stats</th><th></th></tr></thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a>
</td>
<td>
<span class="tag task-ui-badge">{{ project.task_count }} task{{ project.task_count|pluralize }}</span>
<span class="tag task-ui-badge">{{ project.epic_count }} epic{{ project.epic_count|pluralize }}</span>
</td>
<td class="has-text-right">
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="project_id" value="{{ project.id }}">
<input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No projects yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
<div class="column">
<article class="box">
<h2 class="title is-6">Recent Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %}
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td>
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
{% if enabled_providers|length == 1 %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
<button class="button is-small is-link is-light" type="submit">
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
</button>
</form>
{% elif enabled_providers %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<div class="field has-addons" style="display:inline-flex;">
<div class="control">
<div class="select is-small">
<select name="provider">
{% for p in enabled_providers %}
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Send</button>
</div>
</div>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="3">No linked identifiers for this person yet.</td></tr>
<tr><td colspan="6">No derived tasks yet.</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="help" style="margin-bottom: 0.75rem;">
Open this page from Compose to map a persons linked identifiers in one click.
</p>
{% endif %}
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Project</th><th>Stats</th><th></th></tr></thead>
<tbody>
{% for project in projects %}
<tr>
<td>
<a href="{% url 'tasks_project' project_id=project.id %}">{{ project.name }}</a>
</td>
<td>
<span class="tag task-ui-badge">{{ project.task_count }} task{{ project.task_count|pluralize }}</span>
<span class="tag task-ui-badge">{{ project.epic_count }} epic{{ project.epic_count|pluralize }}</span>
</td>
<td class="has-text-right">
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="project_id" value="{{ project.id }}">
<input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No projects yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
<div class="column">
<article class="box">
<h2 class="title is-6">Recent Derived Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Project</th><th>Status</th><th>Actions</th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_identifier %}
<div class="has-text-grey"><code>{{ row.creator_identifier }}</code></div>
{% endif %}
</td>
<td>{{ row.project.name }}{% if row.epic %} / {{ row.epic.name }}{% endif %}</td>
<td>{{ row.status_snapshot }}</td>
<td>
<a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a>
{% if enabled_providers|length == 1 %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<input type="hidden" name="provider" value="{{ enabled_providers.0 }}">
<button class="button is-small is-link is-light" type="submit">
Send to {% if enabled_providers.0 == "claude_cli" %}Claude{% else %}Codex{% endif %}
</button>
</form>
{% elif enabled_providers %}
<form method="post" action="{% url 'tasks_codex_submit' %}" style="display:inline;">
{% csrf_token %}
<input type="hidden" name="task_id" value="{{ row.id }}">
<input type="hidden" name="next" value="{% url 'tasks_hub' %}">
<div class="field has-addons" style="display:inline-flex;">
<div class="control">
<div class="select is-small">
<select name="provider">
{% for p in enabled_providers %}
<option value="{{ p }}">{% if p == "claude_cli" %}Claude{% else %}Codex{% endif %}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Send</button>
</div>
</div>
</form>
{% endif %}
</td>
</tr>
{% empty %}
<tr><td colspan="6">No derived tasks yet.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</article>
</div>
</div>
</div>
</div>
</section>
</section>
{% endblock %}

View File

@@ -1,97 +1,97 @@
{% extends "base.html" %}
{% block content %}
<section class="section">
<div class="container">
<h1 class="title is-4">Project: {{ project.name }}</h1>
<div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete Project</button>
</form>
</div>
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6" style="margin: 0;">Epics</h2>
<span class="tag task-ui-badge">{{ epics|length }}</span>
<section class="section">
<div class="container">
<h1 class="title is-4">Project: {{ project.name }}</h1>
<div class="buttons" style="margin-bottom: 0.75rem;">
<a class="button is-small is-light" href="{% url 'tasks_hub' %}">Back</a>
<form method="post" onsubmit="const v=prompt('Type {{ project.name|escapejs }} to confirm delete'); if(v===null){return false;} this.confirm_name.value=v; return true;">
{% csrf_token %}
<input type="hidden" name="action" value="project_delete">
<input type="hidden" name="confirm_name" value="">
<button class="button is-small is-danger is-light" type="submit">Delete Project</button>
</form>
</div>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="epic_create">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New epic name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add Epic</button>
</div>
</div>
</form>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Epic</th><th>Tasks</th><th></th></tr></thead>
<tbody>
{% for epic in epics %}
<tr>
<td><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></td>
<td><span class="tag task-ui-badge">{{ epic.task_count }}</span></td>
<td class="has-text-right">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="epic_delete">
<input type="hidden" name="epic_id" value="{{ epic.id }}">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No epics.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Epic</th><th></th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_compose_href %}
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div>
{% endif %}
</td>
<td>
<form method="post" class="is-flex" style="gap: 0.35rem; align-items: center;">
{% csrf_token %}
<input type="hidden" name="action" value="task_set_epic">
<input type="hidden" name="task_id" value="{{ row.id }}">
<div class="select is-small">
<select name="epic_id">
<option value="">No epic</option>
{% for epic in epics %}
<option value="{{ epic.id }}" {% if row.epic_id == epic.id %}selected{% endif %}>{{ epic.name }}</option>
{% endfor %}
</select>
</div>
<button class="button is-small is-light" type="submit">Set</button>
</form>
</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No tasks.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
<article class="box">
<div class="is-flex is-justify-content-space-between is-align-items-center" style="gap: 0.5rem; margin-bottom: 0.6rem;">
<h2 class="title is-6" style="margin: 0;">Epics</h2>
<span class="tag task-ui-badge">{{ epics|length }}</span>
</div>
<form method="post" style="margin-bottom: 0.75rem;">
{% csrf_token %}
<input type="hidden" name="action" value="epic_create">
<div class="field has-addons">
<div class="control is-expanded">
<input class="input is-small" name="name" placeholder="New epic name">
</div>
<div class="control">
<button class="button is-small is-link is-light" type="submit">Add Epic</button>
</div>
</div>
</form>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Epic</th><th>Tasks</th><th></th></tr></thead>
<tbody>
{% for epic in epics %}
<tr>
<td><a href="{% url 'tasks_epic' epic_id=epic.id %}">{{ epic.name }}</a></td>
<td><span class="tag task-ui-badge">{{ epic.task_count }}</span></td>
<td class="has-text-right">
<form method="post">
{% csrf_token %}
<input type="hidden" name="action" value="epic_delete">
<input type="hidden" name="epic_id" value="{{ epic.id }}">
<button class="button is-small is-danger is-light" type="submit">Delete</button>
</form>
</td>
</tr>
{% empty %}
<tr><td colspan="3">No epics.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
<article class="box">
<h2 class="title is-6">Tasks</h2>
<table class="table is-fullwidth is-striped is-size-7">
<thead><tr><th>Ref</th><th>Title</th><th>Created By</th><th>Epic</th><th></th></tr></thead>
<tbody>
{% for row in tasks %}
<tr>
<td>#{{ row.reference_code }}</td>
<td>{{ row.title }}</td>
<td>
{{ row.creator_label|default:"Unknown" }}
{% if row.creator_compose_href %}
<div><a class="is-size-7" href="{{ row.creator_compose_href }}">Compose</a></div>
{% endif %}
</td>
<td>
<form method="post" class="is-flex" style="gap: 0.35rem; align-items: center;">
{% csrf_token %}
<input type="hidden" name="action" value="task_set_epic">
<input type="hidden" name="task_id" value="{{ row.id }}">
<div class="select is-small">
<select name="epic_id">
<option value="">No epic</option>
{% for epic in epics %}
<option value="{{ epic.id }}" {% if row.epic_id == epic.id %}selected{% endif %}>{{ epic.name }}</option>
{% endfor %}
</select>
</div>
<button class="button is-small is-light" type="submit">Set</button>
</form>
</td>
<td><a class="button is-small is-light" href="{% url 'tasks_task' task_id=row.id %}">Open</a></td>
</tr>
{% empty %}
<tr><td colspan="5">No tasks.</td></tr>
{% endfor %}
</tbody>
</table>
</article>
</div>
</section>
{% endblock %}

File diff suppressed because it is too large Load Diff

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>
@@ -399,8 +399,8 @@
return String(value || "")
.split(",")
.map(function (item) {
return item.trim();
})
return item.trim();
})
.filter(Boolean);
};

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>
@@ -867,35 +867,35 @@
};
defineGlobal("giaMitigationToggleEdit", function(button) {
const form = button && button.closest ? button.closest("form") : null;
if (!form) return;
const editing = button.dataset.editState === "edit";
if (!editing) {
form.querySelectorAll('[data-editable="1"]').forEach(function(field) { field.removeAttribute("readonly"); });
form.querySelectorAll('[data-editable-toggle="1"]').forEach(function(field) { field.removeAttribute("disabled"); });
const card = form.closest(".mitigation-artifact-card");
if (card) card.classList.add("is-editing");
button.dataset.editState = "edit";
button.classList.remove("is-light");
button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
resizeEditableTextareas(form);
return;
}
form.requestSubmit();
const form = button && button.closest ? button.closest("form") : null;
if (!form) return;
const editing = button.dataset.editState === "edit";
if (!editing) {
form.querySelectorAll('[data-editable="1"]').forEach(function(field) { field.removeAttribute("readonly"); });
form.querySelectorAll('[data-editable-toggle="1"]').forEach(function(field) { field.removeAttribute("disabled"); });
const card = form.closest(".mitigation-artifact-card");
if (card) card.classList.add("is-editing");
button.dataset.editState = "edit";
button.classList.remove("is-light");
button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
resizeEditableTextareas(form);
return;
}
form.requestSubmit();
});
defineGlobal("giaEngageSetAction", function(pid, action) {
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) actionInput.value = action;
if (action === "send") window.giaEngageSyncSendOverride(pid);
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) actionInput.value = action;
if (action === "send") window.giaEngageSyncSendOverride(pid);
});
defineGlobal("giaEngageAutoPreview", function(pid) {
const form = document.getElementById("engage-form-" + pid);
if (!form) return;
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
const form = document.getElementById("engage-form-" + pid);
if (!form) return;
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
});
window.giaEngageSetTarget = function(pid, targetId) {
@@ -908,14 +908,14 @@
};
defineGlobal("giaEngageSelect", function(pid, kind, value, node) {
const inputId = kind === "share" ? ("engage-share-input-" + pid) : (kind === "framing" ? ("engage-framing-input-" + pid) : "");
const input = inputId ? document.getElementById(inputId) : null;
if (input) input.value = value;
const li = node && node.closest ? node.closest("li") : null;
if (!li || !li.parentElement) return;
Array.from(li.parentElement.children).forEach(function(child) { child.classList.remove("is-active"); });
li.classList.add("is-active");
window.giaEngageAutoPreview(pid);
const inputId = kind === "share" ? ("engage-share-input-" + pid) : (kind === "framing" ? ("engage-framing-input-" + pid) : "");
const input = inputId ? document.getElementById(inputId) : null;
if (input) input.value = value;
const li = node && node.closest ? node.closest("li") : null;
if (!li || !li.parentElement) return;
Array.from(li.parentElement.children).forEach(function(child) { child.classList.remove("is-active"); });
li.classList.add("is-active");
window.giaEngageAutoPreview(pid);
});
window.giaMitigationShowTab(personId, "{{ active_tab|default:'plan_board' }}");

View File

@@ -536,8 +536,8 @@
showOperationPane(operation);
const activeTab = tabKey || (
operation === "artifacts"
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
: operation
? ((window.giaWorkspaceState[personId] || {}).currentMitigationTab || "plan_board")
: operation
);
setTopCapsuleActive(activeTab);
const hydrated = hydrateCachedIfAvailable(operation);
@@ -573,8 +573,8 @@
const currentState = window.giaWorkspaceState[personId] || {};
const targetTabKey = currentState.pendingTabKey || (
operation === "artifacts"
? (currentState.currentMitigationTab || "plan_board")
: operation
? (currentState.currentMitigationTab || "plan_board")
: operation
);
if (!forceRefresh && currentState.current === operation && pane.dataset.loaded === "1") {
window.giaWorkspaceShowTab(personId, operation, targetTabKey);
@@ -622,38 +622,38 @@
fetch(url, { method: "GET" })
.then(function(resp) { return resp.text(); })
.then(function(html) {
pane.innerHTML = html;
pane.dataset.loaded = "1";
executeInlineScripts(pane);
pane.classList.remove("ai-animate-in");
void pane.offsetWidth;
pane.classList.add("ai-animate-in");
if (cacheAllowed) {
window.giaWorkspaceCache[key] = {
html: html,
ts: Date.now(),
};
persistCache();
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
} else {
setCachedIndicator(false, null);
}
if (window.htmx) {
window.htmx.process(pane);
}
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0);
}
if (operation === "artifacts") {
applyMitigationTabSelection();
}
if (window.giaWorkspaceState[personId]) {
window.giaWorkspaceState[personId].pendingTabKey = "";
}
})
pane.innerHTML = html;
pane.dataset.loaded = "1";
executeInlineScripts(pane);
pane.classList.remove("ai-animate-in");
void pane.offsetWidth;
pane.classList.add("ai-animate-in");
if (cacheAllowed) {
window.giaWorkspaceCache[key] = {
html: html,
ts: Date.now(),
};
persistCache();
setCachedIndicator(true, window.giaWorkspaceCache[key].ts);
} else {
setCachedIndicator(false, null);
}
if (window.htmx) {
window.htmx.process(pane);
}
if (operation === "draft_reply" && typeof window.giaWorkspaceUseDraft === "function") {
window.giaWorkspaceUseDraft(personId, operation, 0);
}
if (operation === "artifacts") {
applyMitigationTabSelection();
}
if (window.giaWorkspaceState[personId]) {
window.giaWorkspaceState[personId].pendingTabKey = "";
}
})
.catch(function() {
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
});
pane.innerHTML = '<div class="notification is-danger is-light ai-animate-in">Failed to load AI response.</div>';
});
};
window.giaWorkspaceRefresh = function(pid) {
@@ -663,8 +663,8 @@
const state = window.giaWorkspaceState[personId] || {};
const currentTab = state.currentTab || (
state.current === "artifacts"
? (state.currentMitigationTab || "plan_board")
: (state.current || "plan_board")
? (state.currentMitigationTab || "plan_board")
: (state.current || "plan_board")
);
window.giaWorkspaceOpenTab(personId, currentTab, true);
};
@@ -754,15 +754,15 @@
})
.then(function(resp) { return resp.text(); })
.then(function(html) {
if (statusHost) {
statusHost.innerHTML = html;
}
})
if (statusHost) {
statusHost.innerHTML = html;
}
})
.catch(function() {
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
}
});
if (statusHost) {
statusHost.innerHTML = '<div class="notification is-danger is-light" style="padding: 0.45rem 0.6rem;">Failed to queue draft.</div>';
}
});
};
function getSelectedTargetId() {
@@ -841,92 +841,92 @@
};
defineGlobal("giaMitigationShowTab", function(pid, tabName) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
names.forEach(function(name) {
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
if (!pane) {
return;
}
const active = (name === tabName);
pane.style.display = active ? "block" : "none";
if (tab) {
tab.classList.toggle("is-active", active);
}
});
const shell = document.getElementById("mitigation-shell-" + pid);
if (!shell) {
const names = ["plan_board", "corrections", "engage", "fundamentals", "ask_ai"];
names.forEach(function(name) {
const pane = document.getElementById("mitigation-tab-" + pid + "-" + name);
const tab = document.getElementById("mitigation-tab-btn-" + pid + "-" + name);
if (!pane) {
return;
}
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
input.value = tabName;
});
const active = (name === tabName);
pane.style.display = active ? "block" : "none";
if (tab) {
tab.classList.toggle("is-active", active);
}
});
const shell = document.getElementById("mitigation-shell-" + pid);
if (!shell) {
return;
}
shell.querySelectorAll('input[name="active_tab"]').forEach(function(input) {
input.value = tabName;
});
});
defineGlobal("giaMitigationToggleEdit", function(button) {
const form = button ? button.closest("form") : null;
if (!form) {
return;
const form = button ? button.closest("form") : null;
if (!form) {
return;
}
const card = form.closest(".mitigation-artifact-card");
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
toggles.forEach(function(field) {
field.removeAttribute("disabled");
});
if (card) {
card.classList.add("is-editing");
}
const card = form.closest(".mitigation-artifact-card");
const editing = button.dataset.editState === "edit";
const fields = form.querySelectorAll('[data-editable="1"]');
const toggles = form.querySelectorAll('[data-editable-toggle="1"]');
if (!editing) {
fields.forEach(function(field) {
field.removeAttribute("readonly");
});
toggles.forEach(function(field) {
field.removeAttribute("disabled");
});
if (card) {
card.classList.add("is-editing");
}
button.dataset.editState = "edit";
button.classList.remove("is-light");
button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
} else {
form.requestSubmit();
}
});
button.dataset.editState = "edit";
button.classList.remove("is-light");
button.title = "Save";
button.innerHTML = '<span class="icon is-small"><i class="fa-solid fa-check"></i></span>';
} else {
form.requestSubmit();
}
});
defineGlobal("giaEngageSetAction", function(pid, action) {
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) {
actionInput.value = action;
}
});
const actionInput = document.getElementById("engage-action-input-" + pid);
if (actionInput) {
actionInput.value = action;
}
});
defineGlobal("giaEngageAutoPreview", function(pid) {
const form = document.getElementById("engage-form-" + pid);
if (!form) {
return;
}
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
});
const form = document.getElementById("engage-form-" + pid);
if (!form) {
return;
}
window.giaEngageSetAction(pid, "preview");
form.requestSubmit();
});
defineGlobal("giaEngageSelect", function(pid, kind, value, node) {
let inputId = "";
if (kind === "share") {
inputId = "engage-share-input-" + pid;
} else if (kind === "framing") {
inputId = "engage-framing-input-" + pid;
}
const input = inputId ? document.getElementById(inputId) : null;
if (input) {
input.value = value;
}
const li = node && node.closest ? node.closest("li") : null;
if (li && li.parentElement) {
Array.from(li.parentElement.children).forEach(function(child) {
child.classList.remove("is-active");
});
li.classList.add("is-active");
}
window.giaEngageAutoPreview(pid);
});
let inputId = "";
if (kind === "share") {
inputId = "engage-share-input-" + pid;
} else if (kind === "framing") {
inputId = "engage-framing-input-" + pid;
}
const input = inputId ? document.getElementById(inputId) : null;
if (input) {
input.value = value;
}
const li = node && node.closest ? node.closest("li") : null;
if (li && li.parentElement) {
Array.from(li.parentElement.children).forEach(function(child) {
child.classList.remove("is-active");
});
li.classList.add("is-active");
}
window.giaEngageAutoPreview(pid);
});
window.giaWorkspaceOpenTab(personId, "plan_board", false);
syncTargetInputs();

View File

@@ -2323,8 +2323,8 @@
glanceState && glanceState.gap ? glanceState.gap.lag_ms : 0
);
const baselineMs = baselineFromGapMs > 0
? baselineFromGapMs
: toInt(snapshot.counterpartBaselineMs);
? baselineFromGapMs
: toInt(snapshot.counterpartBaselineMs);
if (!baselineMs) {
replyTimingState = {
sinceLabel: sinceLabel,
@@ -2758,13 +2758,13 @@
const safe = Array.isArray(items) ? items.slice(0, 3) : [];
const ordered = safe
.filter(function (item) {
return /^delay$/i.test(String(item && item.label ? item.label : ""));
})
.concat(
safe.filter(function (item) {
return !/^delay$/i.test(String(item && item.label ? item.label : ""));
return /^delay$/i.test(String(item && item.label ? item.label : ""));
})
);
.concat(
safe.filter(function (item) {
return !/^delay$/i.test(String(item && item.label ? item.label : ""));
})
);
glanceNode.innerHTML = "";
ordered.forEach(function (item) {
const url = String(item.url || "").trim();
@@ -3326,11 +3326,11 @@
bubble.appendChild(blockGap);
}
const imageCandidatesFromPayload = Array.isArray(msg.image_urls) && msg.image_urls.length
? msg.image_urls
: (msg.image_url ? [msg.image_url] : []);
? msg.image_urls
: (msg.image_url ? [msg.image_url] : []);
const imageCandidates = imageCandidatesFromPayload.length
? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || "");
? imageCandidatesFromPayload
: extractUrlCandidates(msg.text || msg.display_text || "");
appendImageCandidates(bubble, imageCandidates);
if (!msg.hide_text) {
@@ -3376,8 +3376,8 @@
const deletedFlag = document.createElement("span");
deletedFlag.className = "compose-msg-flag is-deleted";
deletedFlag.title = "Deleted"
+ (msg.deleted_display ? (" at " + String(msg.deleted_display)) : "")
+ (msg.deleted_actor ? (" by " + String(msg.deleted_actor)) : "");
+ (msg.deleted_display ? (" at " + String(msg.deleted_display)) : "")
+ (msg.deleted_actor ? (" by " + String(msg.deleted_actor)) : "");
deletedFlag.textContent = "deleted";
meta.appendChild(deletedFlag);
}
@@ -5184,7 +5184,7 @@
} catch (err) {
setCardLoading(card, false);
card.querySelector(".compose-ai-content").textContent =
"Failed to load quick insights.";
"Failed to load quick insights.";
}
};
@@ -5203,8 +5203,8 @@
const customText = card.querySelector(".engage-custom-text");
const selectedSource = (
preferredSource !== undefined
? preferredSource
: (sourceSelect ? sourceSelect.value : "")
? preferredSource
: (sourceSelect ? sourceSelect.value : "")
);
const customValue = customText ? String(customText.value || "").trim() : "";
const showCustom = selectedSource === "custom";
@@ -5382,8 +5382,8 @@
const selectedPerson = selected.dataset.person || thread.dataset.person || "";
const selectedPageUrl = (
renderMode === "page"
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
) || "";
switchThreadContext(
selectedService,
@@ -5412,8 +5412,8 @@
const selectedPerson = selected.dataset.person || "";
let selectedPageUrl = (
renderMode === "page"
? selected.dataset[servicePageUrlKey]
: selected.dataset[serviceWidgetUrlKey]
? selected.dataset[servicePageUrlKey]
: selected.dataset[serviceWidgetUrlKey]
) || "";
if (!selectedIdentifier) {
selectedService = selected.dataset.service || selectedService;
@@ -5422,8 +5422,8 @@
if (!selectedPageUrl) {
selectedPageUrl = (
renderMode === "page"
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
? selected.dataset.pageUrl
: selected.dataset.widgetUrl
) || "";
}
switchThreadContext(

View File

@@ -3,10 +3,10 @@
<div class="column is-12-mobile is-12-tablet">
<div
style="
margin-bottom: 0.75rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
">
margin-bottom: 0.75rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
">
<p class="is-size-7 has-text-weight-semibold">Manual Workspace</p>
<h3 class="title is-6" style="margin-bottom: 0.5rem;">Choose A Contact</h3>
<p class="is-size-7">
@@ -17,10 +17,10 @@
<form
id="compose-workspace-window-form"
style="
margin-bottom: 0.75rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
">
margin-bottom: 0.75rem;
padding: 0.5rem 0.25rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.12);
">
<label class="label is-small" for="compose-workspace-limit">Window</label>
<div class="select is-fullwidth is-small">
<select id="compose-workspace-limit" name="limit">
@@ -43,12 +43,12 @@
<button
class="button is-fullwidth"
style="
border-radius: 8px;
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
"
border-radius: 8px;
border: 0;
background: transparent;
box-shadow: none;
padding: 0;
"
hx-get="{{ row.compose_widget_url }}"
hx-include="#compose-workspace-window-form"
hx-target="#widgets-here"
@@ -56,42 +56,42 @@
<span
class="tags has-addons"
style="
display: inline-flex;
width: 100%;
margin: 0;
white-space: nowrap;
">
display: inline-flex;
width: 100%;
margin: 0;
white-space: nowrap;
">
<span
class="tag is-white"
style="
flex: 1;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-left: 0.7rem;
padding-right: 0.7rem;
border: 1px solid rgba(0, 0, 0, 0.2);
min-width: 0;
">
flex: 1;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding-left: 0.7rem;
padding-right: 0.7rem;
border: 1px solid rgba(0, 0, 0, 0.2);
min-width: 0;
">
<span
style="
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
min-width: 0;
">
display: inline-flex;
align-items: baseline;
gap: 0.45rem;
min-width: 0;
">
<strong>{{ row.person_name }}</strong>
<small class="has-text-grey">{{ row.service|title }}</small>
</span>
<small
class="has-text-grey"
style="
min-width: 0;
overflow-wrap: anywhere;
word-break: break-all;
text-align: right;
">
min-width: 0;
overflow-wrap: anywhere;
word-break: break-all;
text-align: right;
">
{{ row.identifier }}
</small>
</span>

View File

@@ -153,14 +153,14 @@
</label>
</div>
</div>
<div class="content is-size-7" style="margin-top: 0.2rem;">
<ul>
<li><strong>Min/Max Sent.</strong>: sentiment bounds for people/contact results (-1 to 1).</li>
<li><strong>Annotate snippets</strong>: shows contextual snippets around query hits.</li>
<li><strong>Deduplicate</strong>: removes near-identical repeated rows.</li>
<li><strong>Reverse output</strong>: reverses final result order after sorting.</li>
</ul>
</div>
<div class="content is-size-7" style="margin-top: 0.2rem;">
<ul>
<li><strong>Min/Max Sent.</strong>: sentiment bounds for people/contact results (-1 to 1).</li>
<li><strong>Annotate snippets</strong>: shows contextual snippets around query hits.</li>
<li><strong>Deduplicate</strong>: removes near-identical repeated rows.</li>
<li><strong>Reverse output</strong>: reverses final result order after sorting.</li>
</ul>
</div>
</div>
</details>
</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

@@ -3,9 +3,9 @@
<div class="tabs is-boxed is-small mb-4 security-page-tabs">
<ul>
{% for tab in settings_nav.tabs %}
<li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a>
</li>
<li class="{% if tab.active %}is-active{% endif %}">
<a href="{{ tab.href }}">{{ tab.label }}</a>
</li>
{% endfor %}
</ul>
</div>

View File

@@ -1,154 +1,154 @@
{% include 'mixins/partials/notify.html' %}
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>number</th>
<th>uuid</th>
<th>account</th>
<th>name</th>
<th>person</th>
<th>availability</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{% if item.chat %}{{ item.chat.source_number }}{% endif %}</td>
<td>
{% if item.chat %}
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
{% endif %}
</td>
<td>{% if item.chat %}{{ item.chat.account }}{% endif %}</td>
<td>
{% if item.is_group %}
<span class="tag is-info is-light is-small mr-1"><i class="fa-solid fa-users"></i></span>
{% endif %}
{% if item.chat %}{{ item.chat.source_name }}{% else %}{{ item.name }}{% endif %}
</td>
<td>{{ item.person_name|default:"-" }}</td>
<td>
{% if item.availability_label %}
<span class="tag is-light">{{ item.availability_label }}</span>
{% else %}
-
{% endif %}
</td>
<td>
<div class="buttons">
{% if not item.is_group %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{# url 'account_delete' type=type pk=item.id #}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
<table
class="table is-fullwidth is-hoverable"
hx-target="#{{ context_object_name }}-table"
id="{{ context_object_name }}-table"
hx-swap="outerHTML"
hx-trigger="{{ context_object_name_singular }}Event from:body"
hx-get="{{ list_url }}">
<thead>
<th>number</th>
<th>uuid</th>
<th>account</th>
<th>name</th>
<th>person</th>
<th>availability</th>
<th>actions</th>
</thead>
{% for item in object_list %}
<tr>
<td>{% if item.chat %}{{ item.chat.source_number }}{% endif %}</td>
<td>
{% if item.chat %}
<a
class="has-text-grey button nowrap-child"
onclick="window.prompt('Copy to clipboard: Ctrl+C, Enter', '{{ item.chat.source_uuid }}');">
<span class="icon" data-tooltip="Copy to clipboard">
<i class="fa-solid fa-copy" aria-hidden="true"></i>
</span>
</a>
{% endif %}
</td>
<td>{% if item.chat %}{{ item.chat.account }}{% endif %}</td>
<td>
{% if item.is_group %}
<span class="tag is-info is-light is-small mr-1"><i class="fa-solid fa-users"></i></span>
{% endif %}
{% if item.chat %}{{ item.chat.source_name }}{% else %}{{ item.name }}{% endif %}
</td>
<td>{{ item.person_name|default:"-" }}</td>
<td>
{% if item.availability_label %}
<span class="tag is-light">{{ item.availability_label }}</span>
{% else %}
-
{% endif %}
</td>
<td>
<div class="buttons">
{% if not item.is_group %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-delete="{# url 'account_delete' type=type pk=item.id #}"
hx-trigger="click"
hx-target="#modals-here"
hx-swap="innerHTML"
hx-confirm="Are you sure you wish to unlink {{ item.chat }}?"
class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-xmark"></i>
</span>
</button>
{% endif %}
{% if type == 'page' %}
{% if item.can_compose %}
<a href="{{ item.compose_page_url }}"><button
class="button"
title="Manual text mode">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
</a>
{% else %}
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button
class="button"
title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button>
</a>
{% endif %}
<a href="{{ item.ai_url }}"><button
</span>
</button>
{% endif %}
{% if type == 'page' %}
{% if item.can_compose %}
<a href="{{ item.compose_page_url }}"><button
class="button"
title="Open AI workspace">
title="Manual text mode">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-brain-circuit"></i>
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
</a>
{% else %}
{% if item.can_compose %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% else %}
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button></a>
{% endif %}
<a href="{{ item.ai_url }}"><button class="button">
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-brain-circuit"></i>
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button
class="button"
title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button>
</a>
{% endif %}
<a href="{{ item.ai_url }}"><button
class="button"
title="Open AI workspace">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-brain-circuit"></i>
</span>
</span>
</button>
</a>
{% else %}
{% if item.can_compose %}
<button
hx-headers='{"X-CSRFToken": "{{ csrf_token }}"}'
hx-get="{{ item.compose_widget_url }}"
hx-trigger="click"
hx-target="#widgets-here"
hx-swap="afterend"
class="button">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% else %}
<button class="button" disabled title="No identifier available for manual send">
<span class="icon-text">
<span class="icon">
<i class="{{ item.manual_icon_class }}"></i>
</span>
</span>
</button>
{% endif %}
{% if not item.is_group %}
<a href="{{ item.match_url }}"><button class="button" title="Match identifier to person">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-link"></i>
</span>
</span>
</button></a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
<a href="{{ item.ai_url }}"><button class="button">
<span class="icon-text">
<span class="icon">
<i class="fa-solid fa-brain-circuit"></i>
</span>
</span>
</button></a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</table>
</table>

View File

@@ -4,12 +4,12 @@
src="data:image/png;base64, {{ object.image_b64 }}"
alt="WhatsApp QR code"
style="
display: block;
width: 100%;
max-width: 420px;
height: auto;
margin: 0 auto;
" />
display: block;
width: 100%;
max-width: 420px;
height: auto;
margin: 0 auto;
" />
{% if object.warning %}
<p class="is-size-7" style="margin-top: 0.6rem;">{{ object.warning }}</p>
{% endif %}

View File

@@ -3,12 +3,12 @@
<div class="buttons">
{% if cancel_url %}
<a href="{{ cancel_url }}"
class="button">{% trans "Cancel" %}</a>
class="button">{% trans "Cancel" %}</a>
{% endif %}
{% if wizard.steps.prev %}
<button name="wizard_goto_step" type="submit"
value="{{ wizard.steps.prev }}"
class="button">{% trans "Back" %}</button>
value="{{ wizard.steps.prev }}"
class="button">{% trans "Back" %}</button>
{% else %}
<button disabled name="" type="button" class="button">{% trans "Back" %}</button>
{% endif %}

View File

@@ -22,7 +22,7 @@
<form method="post">{% csrf_token %}{{ form }}
<a href="{% url 'security_2fa' %}"
class="float-right button">{% trans "Back to Account Security" %}</a>
class="float-right button">{% trans "Back to Account Security" %}</a>
<button class="button" type="submit">{% trans "Generate Tokens" %}</button>
</form>
{% endblock %}

View File

@@ -34,7 +34,7 @@
<p class="subtitle">
{% for other in other_devices %}
<button name="challenge_device" value="{{ other.persistent_id }}"
class="button" type="submit">
class="button" type="submit">
{{ other.generate_challenge_button_title }}
</button>
{% endfor %}</p>
@@ -43,7 +43,7 @@
<p class="subtitle">{% trans "As a last resort, you can use a backup token:" %}</p>
<p class="subtitle">
<button name="wizard_goto_step" type="submit" value="backup"
class="button">{% trans "Use Backup Token" %}</button>
class="button">{% trans "Use Backup Token" %}</button>
</p>
{% endif %}

View File

@@ -14,7 +14,7 @@
<div class="buttons">
<a href="javascript:history.go(-1)"
class="float-right button">{% trans "Go back" %}</a>
class="float-right button">{% trans "Go back" %}</a>
<a href="{% url 'two_factor:setup' %}" class="button">
{% trans "Enable Two-Factor Authentication" %}</a>
</div>

View File

@@ -9,16 +9,16 @@
{% if not phone_methods %}
<p class="subtitle"><a href="{% url 'security_2fa' %}"
class="button">{% trans "Back to Account Security" %}</a></p>
class="button">{% trans "Back to Account Security" %}</a></p>
{% else %}
<p class="subtitle">{% blocktrans trimmed %}However, it might happen that you don't have access to
your primary token device. To enable account recovery, add a phone
number.{% endblocktrans %}</p>
<a href="{% url 'security_2fa' %}"
class="float-right button">{% trans "Back to Account Security" %}</a>
class="float-right button">{% trans "Back to Account Security" %}</a>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p>
class="button">{% trans "Add Phone Number" %}</a></p>
{% endif %}
{% endblock %}

View File

@@ -9,6 +9,6 @@
{% csrf_token %}
<table>{{ form }}</table>
<button class="button"
type="submit">{% trans "Disable" %}</button>
type="submit">{% trans "Disable" %}</button>
</form>
{% endblock %}

View File

@@ -22,16 +22,16 @@
<li>
{{ phone.generate_challenge_button_title }}
<form method="post" action="{% url 'two_factor:phone_delete' phone.id %}"
onsubmit="return confirm({% trans 'Are you sure?' %})">
onsubmit="return confirm({% trans 'Are you sure?' %})">
{% csrf_token %}
<button class="button is-warning"
type="submit">{% trans "Unregister" %}</button>
type="submit">{% trans "Unregister" %}</button>
</form>
</li>
{% endfor %}
</ul>
<p class="subtitle"><a href="{% url 'two_factor:phone_create' %}"
class="button">{% trans "Add Phone Number" %}</a></p>
class="button">{% trans "Add Phone Number" %}</a></p>
{% endif %}
<h2 class="title is-4">{% trans "Backup Tokens" %}</h2>
@@ -45,7 +45,7 @@
{% endblocktrans %}
</p>
<p class="subtitle"><a href="{% url 'two_factor:backup_tokens' %}"
class="button">{% trans "Show Codes" %}</a></p>
class="button">{% trans "Show Codes" %}</a></p>
<h3 class="title is-5">{% trans "Disable Two-Factor Authentication" %}</h3>
<p class="subtitle">{% blocktrans trimmed %}However we strongly discourage you to do so, you can

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(

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