Add CI workflow #27
12 changed files with 233 additions and 134 deletions
13
.github/workflows/ci.yaml
vendored
Normal file
13
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
python:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
|
||||
with:
|
||||
source-dirs: "src"
|
||||
run-sam-validate: false
|
||||
typescript:
|
||||
uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
|
||||
|
|
@ -4,6 +4,11 @@ from decimal import Decimal
|
|||
|
||||
import boto3
|
||||
|
||||
from conversation.tools import TOOLS, dispatch_tool, set_tool_context
|
||||
from shared.dynamo import get_conversation, save_conversation
|
||||
from shared.secrets import get_slack_token
|
||||
from shared.slack import update_message, post_message
|
||||
|
||||
|
||||
class _DecimalEncoder(json.JSONEncoder):
|
||||
def default(self, o):
|
||||
|
|
@ -11,10 +16,6 @@ class _DecimalEncoder(json.JSONEncoder):
|
|||
return int(o) if o == int(o) else float(o)
|
||||
return super().default(o)
|
||||
|
||||
from shared.dynamo import get_conversation, save_conversation
|
||||
from shared.secrets import get_slack_token
|
||||
from shared.slack import update_message, post_message
|
||||
from conversation.tools import TOOLS, dispatch_tool, set_tool_context
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
|
@ -99,15 +100,21 @@ def lambda_handler(event, context):
|
|||
if placeholder_ts:
|
||||
update_message(channel, placeholder_ts, final_text, token)
|
||||
elif coordination:
|
||||
post_message(channel, final_text, token,
|
||||
thread_ts=coordination["coord_thread_ts"])
|
||||
post_message(
|
||||
channel, final_text, token, thread_ts=coordination["coord_thread_ts"]
|
||||
)
|
||||
elif thread_ts:
|
||||
post_message(channel, final_text, token, thread_ts=thread_ts)
|
||||
else:
|
||||
post_message(channel, final_text, token)
|
||||
|
||||
logger.info("Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s",
|
||||
channel, thread_ts, len(messages), is_dm)
|
||||
logger.info(
|
||||
"Conversation complete: channel=%s, thread=%s, turns=%d, is_dm=%s",
|
||||
channel,
|
||||
thread_ts,
|
||||
len(messages),
|
||||
is_dm,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Conversation failed for thread %s", thread_ts)
|
||||
try:
|
||||
|
|
@ -143,14 +150,20 @@ def _run_conversation(messages, system):
|
|||
for block in assistant_message["content"]:
|
||||
tool_use = block.get("toolUse")
|
||||
if tool_use:
|
||||
logger.info("Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"]))
|
||||
logger.info(
|
||||
"Tool call: %s(%s)", tool_use["name"], json.dumps(tool_use["input"])
|
||||
)
|
||||
result = dispatch_tool(tool_use["name"], tool_use["input"])
|
||||
tool_results.append({
|
||||
"toolResult": {
|
||||
"toolUseId": tool_use["toolUseId"],
|
||||
"content": [{"text": json.dumps(result, cls=_DecimalEncoder)}],
|
||||
tool_results.append(
|
||||
{
|
||||
"toolResult": {
|
||||
"toolUseId": tool_use["toolUseId"],
|
||||
"content": [
|
||||
{"text": json.dumps(result, cls=_DecimalEncoder)}
|
||||
],
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
messages.append({"role": "user", "content": tool_results})
|
||||
|
||||
return assistant_message
|
||||
|
|
@ -161,4 +174,8 @@ def _extract_text(message):
|
|||
for block in message.get("content", []):
|
||||
if "text" in block:
|
||||
parts.append(block["text"])
|
||||
return "\n".join(parts) if parts else "I processed your request but have nothing to report."
|
||||
return (
|
||||
"\n".join(parts)
|
||||
if parts
|
||||
else "I processed your request but have nothing to report."
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,13 @@ from datetime import datetime, timezone
|
|||
import boto3
|
||||
|
||||
from shared.dynamo import (
|
||||
get_todays_messages, get_unanswered_threads, save_coordination,
|
||||
create_task, get_tasks, update_task_status, delete_task,
|
||||
get_todays_messages,
|
||||
get_unanswered_threads,
|
||||
save_coordination,
|
||||
create_task,
|
||||
get_tasks,
|
||||
update_task_status,
|
||||
delete_task,
|
||||
)
|
||||
from shared.gmail import get_authenticated_service, fetch_thread
|
||||
from shared.secrets import get_config, get_slack_token
|
||||
|
|
@ -536,26 +541,38 @@ def _handle_search_inbox(inputs):
|
|||
query = inputs["query"]
|
||||
max_results = inputs.get("max_results", 10)
|
||||
|
||||
resp = service.users().messages().list(
|
||||
userId="me", q=query, maxResults=max_results
|
||||
).execute()
|
||||
resp = (
|
||||
service.users()
|
||||
.messages()
|
||||
.list(userId="me", q=query, maxResults=max_results)
|
||||
.execute()
|
||||
)
|
||||
|
||||
messages = []
|
||||
for msg_meta in resp.get("messages", []):
|
||||
msg = service.users().messages().get(
|
||||
userId="me", id=msg_meta["id"], format="metadata",
|
||||
metadataHeaders=["From", "To", "Subject", "Date"],
|
||||
).execute()
|
||||
msg = (
|
||||
service.users()
|
||||
.messages()
|
||||
.get(
|
||||
userId="me",
|
||||
id=msg_meta["id"],
|
||||
format="metadata",
|
||||
metadataHeaders=["From", "To", "Subject", "Date"],
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
headers = {h["name"]: h["value"] for h in msg["payload"]["headers"]}
|
||||
messages.append({
|
||||
"id": msg["id"],
|
||||
"thread_id": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"snippet": msg.get("snippet", ""),
|
||||
})
|
||||
messages.append(
|
||||
{
|
||||
"id": msg["id"],
|
||||
"thread_id": msg["threadId"],
|
||||
"from": headers.get("From", ""),
|
||||
"to": headers.get("To", ""),
|
||||
"subject": headers.get("Subject", ""),
|
||||
"date": headers.get("Date", ""),
|
||||
"snippet": msg.get("snippet", ""),
|
||||
}
|
||||
)
|
||||
|
||||
return {"query": query, "count": len(messages), "messages": messages}
|
||||
|
||||
|
|
@ -668,8 +685,10 @@ def _handle_get_calendar_events(inputs):
|
|||
{
|
||||
"id": e.get("id", ""),
|
||||
"summary": e.get("summary", "(no title)"),
|
||||
"start": e.get("start", {}).get("dateTime") or e.get("start", {}).get("date", ""),
|
||||
"end": e.get("end", {}).get("dateTime") or e.get("end", {}).get("date", ""),
|
||||
"start": e.get("start", {}).get("dateTime")
|
||||
or e.get("start", {}).get("date", ""),
|
||||
"end": e.get("end", {}).get("dateTime")
|
||||
or e.get("end", {}).get("date", ""),
|
||||
"attendees": [a.get("email", "") for a in e.get("attendees", [])],
|
||||
"location": e.get("location", ""),
|
||||
"status": e.get("status", ""),
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@ def lambda_handler(event, context):
|
|||
thread_msgs = fetch_thread(service, thread_id)
|
||||
adam_replied_after = any(
|
||||
m["from_email"] == config["adam_email"]
|
||||
and m["internal_date"] > _iso_to_epoch_ms(thread.get("unanswered_since", ""))
|
||||
and m["internal_date"]
|
||||
> _iso_to_epoch_ms(thread.get("unanswered_since", ""))
|
||||
for m in thread_msgs
|
||||
)
|
||||
if adam_replied_after:
|
||||
|
|
@ -42,7 +43,9 @@ def lambda_handler(event, context):
|
|||
normal_count = sum(1 for m in messages if m.get("classification") == "NORMAL")
|
||||
low_count = sum(1 for m in messages if m.get("classification") == "LOW")
|
||||
|
||||
blocks = build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count)
|
||||
blocks = build_daily_digest(
|
||||
high_items, bypassed, unanswered, normal_count, low_count
|
||||
)
|
||||
send_dm(
|
||||
config["adam_slack_user_id"],
|
||||
blocks,
|
||||
|
|
|
|||
|
|
@ -2,14 +2,26 @@ import logging
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from shared.secrets import get_config, get_slack_token
|
||||
from shared.gmail import get_authenticated_service, fetch_history, fetch_message, initial_sync
|
||||
from shared.dynamo import message_exists, save_message, save_thread_state, get_sync_metadata, save_sync_metadata
|
||||
from shared.gmail import (
|
||||
get_authenticated_service,
|
||||
fetch_history,
|
||||
fetch_message,
|
||||
initial_sync,
|
||||
)
|
||||
from shared.dynamo import (
|
||||
message_exists,
|
||||
save_message,
|
||||
save_thread_state,
|
||||
get_sync_metadata,
|
||||
save_sync_metadata,
|
||||
)
|
||||
from shared.classify import classify_email, check_bypassed_workorder
|
||||
from shared.slack import send_dm, build_high_priority_alert
|
||||
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def lambda_handler(event, context):
|
||||
config = get_config()
|
||||
service = get_authenticated_service()
|
||||
|
|
@ -47,27 +59,34 @@ def lambda_handler(event, context):
|
|||
|
||||
slack_ts = ""
|
||||
if classification["classification"] == "HIGH":
|
||||
blocks = build_high_priority_alert({
|
||||
**msg,
|
||||
"classification_reason": classification["reason"],
|
||||
})
|
||||
slack_ts = send_dm(
|
||||
config["adam_slack_user_id"],
|
||||
blocks,
|
||||
f"HIGH: {msg['subject']}",
|
||||
get_slack_token(),
|
||||
) or ""
|
||||
blocks = build_high_priority_alert(
|
||||
{
|
||||
**msg,
|
||||
"classification_reason": classification["reason"],
|
||||
}
|
||||
)
|
||||
slack_ts = (
|
||||
send_dm(
|
||||
config["adam_slack_user_id"],
|
||||
blocks,
|
||||
f"HIGH: {msg['subject']}",
|
||||
get_slack_token(),
|
||||
)
|
||||
or ""
|
||||
)
|
||||
results["high"] += 1
|
||||
|
||||
save_message({
|
||||
**msg,
|
||||
"classification": classification["classification"],
|
||||
"classification_reason": classification["reason"],
|
||||
"bypassed_wo": bypassed,
|
||||
"classified_date": today,
|
||||
"notified": bool(slack_ts),
|
||||
"slack_ts": slack_ts,
|
||||
})
|
||||
save_message(
|
||||
{
|
||||
**msg,
|
||||
"classification": classification["classification"],
|
||||
"classification_reason": classification["reason"],
|
||||
"bypassed_wo": bypassed,
|
||||
"classified_date": today,
|
||||
"notified": bool(slack_ts),
|
||||
"slack_ts": slack_ts,
|
||||
}
|
||||
)
|
||||
save_thread_state(msg["thread_id"], msg, config["adam_email"])
|
||||
|
||||
results["processed"] += 1
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ def lambda_handler(event, context):
|
|||
token = get_slack_token()
|
||||
|
||||
import requests
|
||||
|
||||
resp = requests.post(
|
||||
"https://slack.com/api/conversations.open",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
|
|
|
|||
|
|
@ -38,14 +38,18 @@ def get_calendar_service():
|
|||
|
||||
|
||||
def list_events(service, time_min, time_max, max_results=10):
|
||||
result = service.events().list(
|
||||
calendarId="primary",
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
maxResults=max_results,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
).execute()
|
||||
result = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId="primary",
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
maxResults=max_results,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
return result.get("items", [])
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ def classify_email(message, vip_senders, vip_domains):
|
|||
inferenceConfig={"temperature": 0.0, "maxTokens": 256},
|
||||
)
|
||||
raw = resp["output"]["message"]["content"][0]["text"]
|
||||
match = re.search(r'\{[^{}]*\}', raw)
|
||||
match = re.search(r"\{[^{}]*\}", raw)
|
||||
result = json.loads(match.group()) if match else json.loads(raw)
|
||||
return {
|
||||
"classification": result.get("classification", "NORMAL"),
|
||||
|
|
@ -75,7 +75,6 @@ def classify_email(message, vip_senders, vip_domains):
|
|||
|
||||
def check_bypassed_workorder(message, work_order_addresses):
|
||||
all_recipients = [
|
||||
addr.lower()
|
||||
for addr in message["to_emails"] + message["cc_emails"]
|
||||
addr.lower() for addr in message["to_emails"] + message["cc_emails"]
|
||||
]
|
||||
return not any(wo in all_recipients for wo in work_order_addresses)
|
||||
|
|
|
|||
|
|
@ -101,9 +101,7 @@ def save_thread_state(thread_id, message, adam_email):
|
|||
|
||||
|
||||
def get_sync_metadata():
|
||||
resp = _get_table().get_item(
|
||||
Key={"pk": "META#sync", "sk": "META"}
|
||||
)
|
||||
resp = _get_table().get_item(Key={"pk": "META#sync", "sk": "META"})
|
||||
return resp.get("Item")
|
||||
|
||||
|
||||
|
|
@ -139,34 +137,36 @@ def get_todays_messages(date_str):
|
|||
|
||||
|
||||
def get_conversation(thread_ts):
|
||||
resp = _get_table().get_item(
|
||||
Key={"pk": f"CONV#{thread_ts}", "sk": "CONV"}
|
||||
)
|
||||
resp = _get_table().get_item(Key={"pk": f"CONV#{thread_ts}", "sk": "CONV"})
|
||||
item = resp.get("Item")
|
||||
return item["messages"] if item else []
|
||||
|
||||
|
||||
def save_conversation(thread_ts, messages):
|
||||
_get_table().put_item(Item={
|
||||
"pk": f"CONV#{thread_ts}",
|
||||
"sk": "CONV",
|
||||
"messages": messages,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"ttl": _ttl_90_days(),
|
||||
})
|
||||
_get_table().put_item(
|
||||
Item={
|
||||
"pk": f"CONV#{thread_ts}",
|
||||
"sk": "CONV",
|
||||
"messages": messages,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"ttl": _ttl_90_days(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def save_coordination(channel, coord_thread_ts, conversation_thread_ts, started_by):
|
||||
_get_table().put_item(Item={
|
||||
"pk": f"COORD#{channel}#{coord_thread_ts}",
|
||||
"sk": "COORD",
|
||||
"conversation_thread_ts": conversation_thread_ts,
|
||||
"channel": channel,
|
||||
"started_by": started_by,
|
||||
"status": "active",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"ttl": int(time.time()) + (7 * 24 * 60 * 60),
|
||||
})
|
||||
_get_table().put_item(
|
||||
Item={
|
||||
"pk": f"COORD#{channel}#{coord_thread_ts}",
|
||||
"sk": "COORD",
|
||||
"conversation_thread_ts": conversation_thread_ts,
|
||||
"channel": channel,
|
||||
"started_by": started_by,
|
||||
"status": "active",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"ttl": int(time.time()) + (7 * 24 * 60 * 60),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def get_coordination(channel, coord_thread_ts):
|
||||
|
|
@ -203,7 +203,8 @@ def get_unanswered_threads(threshold_hours):
|
|||
items.extend(resp.get("Items", []))
|
||||
|
||||
return [
|
||||
item for item in items
|
||||
item
|
||||
for item in items
|
||||
if datetime.fromisoformat(item["unanswered_since"]).timestamp() < cutoff
|
||||
]
|
||||
|
||||
|
|
@ -213,16 +214,18 @@ def get_unanswered_threads(threshold_hours):
|
|||
|
||||
def create_task(title, due_date=None):
|
||||
task_id = str(ulid.new())
|
||||
_get_table().put_item(Item={
|
||||
"pk": f"TASK#{task_id}",
|
||||
"sk": "TASK",
|
||||
"title": title,
|
||||
"due_date": due_date or "",
|
||||
"status": "open",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"completed_at": "",
|
||||
"ttl": _ttl_90_days(),
|
||||
})
|
||||
_get_table().put_item(
|
||||
Item={
|
||||
"pk": f"TASK#{task_id}",
|
||||
"sk": "TASK",
|
||||
"title": title,
|
||||
"due_date": due_date or "",
|
||||
"status": "open",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"completed_at": "",
|
||||
"ttl": _ttl_90_days(),
|
||||
}
|
||||
)
|
||||
return task_id
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import google.auth.transport.requests
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ _config = None
|
|||
def get_gmail_oauth():
|
||||
global _gmail_oauth
|
||||
if _gmail_oauth is None:
|
||||
resp = _secrets_client.get_secret_value(
|
||||
SecretId=os.environ["SECRET_GMAIL"]
|
||||
)
|
||||
resp = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_GMAIL"])
|
||||
_gmail_oauth = json.loads(resp["SecretString"])
|
||||
return _gmail_oauth
|
||||
|
||||
|
|
@ -37,9 +35,7 @@ def get_slack_credentials():
|
|||
"""Returns { botToken, signingSecret, appToken }."""
|
||||
global _slack_creds
|
||||
if _slack_creds is None:
|
||||
resp = _secrets_client.get_secret_value(
|
||||
SecretId=os.environ["SECRET_SLACK"]
|
||||
)
|
||||
resp = _secrets_client.get_secret_value(SecretId=os.environ["SECRET_SLACK"])
|
||||
_slack_creds = json.loads(resp["SecretString"])
|
||||
return _slack_creds
|
||||
|
||||
|
|
@ -67,6 +63,8 @@ def get_config():
|
|||
"vip_senders": json.loads(params.get("vip-senders", "[]")),
|
||||
"vip_domains": json.loads(params.get("vip-domains", "[]")),
|
||||
"work_order_addresses": json.loads(params.get("work-order-addresses", "[]")),
|
||||
"unanswered_threshold_hours": int(params.get("unanswered-threshold-hours", "24")),
|
||||
"unanswered_threshold_hours": int(
|
||||
params.get("unanswered-threshold-hours", "24")
|
||||
),
|
||||
}
|
||||
return _config
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
|
@ -46,23 +45,35 @@ def build_high_priority_alert(message):
|
|||
return [
|
||||
{
|
||||
"type": "header",
|
||||
"text": {"type": "plain_text", "text": ":rotating_light: High-Priority Email"},
|
||||
"text": {
|
||||
"type": "plain_text",
|
||||
"text": ":rotating_light: High-Priority Email",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"fields": [
|
||||
{"type": "mrkdwn", "text": f"*From*\n{message['from_name'] or message['from_email']}"},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": f"*From*\n{message['from_name'] or message['from_email']}",
|
||||
},
|
||||
{"type": "mrkdwn", "text": f"*Received*\n{time_ago}"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"type": "section",
|
||||
"text": {"type": "mrkdwn", "text": f"*{message['subject']}*\n{message['snippet'][:300]}"},
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{message['subject']}*\n{message['snippet'][:300]}",
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{"type": "mrkdwn", "text": f":brain: _{message.get('classification_reason', '')}_"},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": f":brain: _{message.get('classification_reason', '')}_",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -90,7 +101,10 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
|
|||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{"type": "mrkdwn", "text": f"{today_str} · {total} emails processed today"},
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": f"{today_str} · {total} emails processed today",
|
||||
},
|
||||
],
|
||||
},
|
||||
{"type": "divider"},
|
||||
|
|
@ -108,7 +122,9 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
|
|||
)
|
||||
if len(high_items) > max_items:
|
||||
lines.append(f" _...and {len(high_items) - max_items} more_")
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}})
|
||||
blocks.append(
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
|
||||
)
|
||||
blocks.append({"type": "divider"})
|
||||
|
||||
if bypassed:
|
||||
|
|
@ -117,10 +133,14 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
|
|||
"_Sent directly to you without CC'ing work-orders@_",
|
||||
]
|
||||
for item in bypassed[:max_items]:
|
||||
lines.append(f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}")
|
||||
lines.append(
|
||||
f" · *{item['subject']}* from {item.get('from_name') or item['from_email']}"
|
||||
)
|
||||
if len(bypassed) > max_items:
|
||||
lines.append(f" _...and {len(bypassed) - max_items} more_")
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}})
|
||||
blocks.append(
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
|
||||
)
|
||||
blocks.append({"type": "divider"})
|
||||
|
||||
if unanswered:
|
||||
|
|
@ -137,22 +157,26 @@ def build_daily_digest(high_items, bypassed, unanswered, normal_count, low_count
|
|||
)
|
||||
if len(unanswered) > max_items:
|
||||
lines.append(f" _...and {len(unanswered) - max_items} more_")
|
||||
blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}})
|
||||
blocks.append(
|
||||
{"type": "section", "text": {"type": "mrkdwn", "text": "\n".join(lines)}}
|
||||
)
|
||||
blocks.append({"type": "divider"})
|
||||
|
||||
blocks.append({
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f":bar_chart: Today: {len(high_items)} high · "
|
||||
f"{normal_count} normal · {low_count} low · "
|
||||
f"{len(bypassed)} bypassed WO · {len(unanswered)} unanswered"
|
||||
),
|
||||
}
|
||||
],
|
||||
})
|
||||
blocks.append(
|
||||
{
|
||||
"type": "context",
|
||||
"elements": [
|
||||
{
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f":bar_chart: Today: {len(high_items)} high · "
|
||||
f"{normal_count} normal · {low_count} low · "
|
||||
f"{len(bypassed)} bypassed WO · {len(unanswered)} unanswered"
|
||||
),
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
return blocks
|
||||
|
||||
|
|
|
|||
Reference in a new issue