Increase security and reformat
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
@@ -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)."
|
||||
)
|
||||
|
||||
@@ -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)})}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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 ""),
|
||||
}
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
39
core/migrations/0042_userxmppomemotrustedkey_and_more.py
Normal file
39
core/migrations/0042_userxmppomemotrustedkey_and_more.py
Normal 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'),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
109
core/models.py
109
core/models.py
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
"""Security helpers shared across transport adapters."""
|
||||
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
99
core/templates/pages/business-plan-inbox.html
Normal file
99
core/templates/pages/business-plan-inbox.html
Normal 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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 person’s 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 person’s 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 %}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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' }}");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -9,6 +9,6 @@
|
||||
{% csrf_token %}
|
||||
<table>{{ form }}</table>
|
||||
<button class="button"
|
||||
type="submit">{% trans "Disable" %}</button>
|
||||
type="submit">{% trans "Disable" %}</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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 "")
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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=[
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user