Add DM fixes, digest stats, tasks/reminders, calendar, and CI/CD pipeline #15

Merged
amoussa1229 merged 6 commits from feature/issues-1-4 into main 2026-05-05 14:45:15 +00:00
14 changed files with 751 additions and 19 deletions

View file

@ -1,6 +1,6 @@
# Exec Aide (Lauren)
Personal AI executive assistant for Adam Moussa. Monitors Gmail inbox, classifies emails by urgency using Claude Haiku (Bedrock), delivers Slack alerts and daily digests, and supports two-way conversation via Slack DM and @mentions in channels.
Personal AI executive assistant for Adam Moussa. Monitors Gmail inbox, classifies emails by urgency using Claude Haiku (Bedrock), delivers Slack alerts and daily digests, manages tasks and reminders, integrates with Google Calendar, and supports two-way conversation via Slack DM and @mentions in channels.
## Architecture
@ -8,17 +8,21 @@ Personal AI executive assistant for Adam Moussa. Monitors Gmail inbox, classifie
EventBridge (15 min) → fetch-classify Lambda → Gmail API → Bedrock Haiku → DynamoDB + Slack DM
EventBridge Scheduler (5 PM ET M-F) → daily-digest Lambda → DynamoDB + Gmail API → Slack DM
Fargate (Socket Mode) → Slack listener → conversation Lambda → Bedrock Sonnet + tools → Slack reply
EventBridge Scheduler (one-shot) → reminder Lambda → Slack DM
```
**Stack:** CDK (TypeScript), stack name `exec-aide`, region us-east-1
**CI/CD:** CodePipeline + CodeBuild — auto-deploys on push to `main`. Pipeline stack: `exec-aide-pipeline`.
### Services
- **exec-aide-fetch-classify** (Lambda): Polls Gmail via History API every 15 minutes, classifies each new message via Bedrock Haiku, filters out marketing, sends immediate Slack DM for HIGH priority, tracks thread state for unanswered detection.
- **exec-aide-daily-digest** (Lambda): Runs weekdays at 5 PM ET (DST-aware). Summarizes high-priority items, bypassed work orders, and unanswered threads older than 24h.
- **exec-aide-conversation** (Lambda): Bedrock Sonnet-powered conversational assistant. Handles multi-turn conversations with tool use for inbox queries, Gmail search, coordination threads, and on-demand digest triggers. Channel-aware: declines private info in public channels. Invoked asynchronously by the listener.
- **exec-aide-conversation** (Lambda): Bedrock Sonnet-powered conversational assistant. Handles multi-turn conversations with tool use for inbox queries, Gmail search, coordination threads, calendar management, tasks, reminders, and on-demand digest triggers. Channel-aware: declines private info in public channels. Invoked asynchronously by the listener.
- **exec-aide-reminder** (Lambda): Receives one-shot EventBridge Scheduler invocations and sends reminder text as a Slack DM to Adam.
- **exec-aide-listener** (Fargate): Socket Mode Slack bot. Handles DMs (with "Thinking..." placeholder UX), @mentions in channels (flat or threaded replies), and coordination thread routing. Runs in a dedicated VPC (10.30.0.0/16) on ARM64.
- **DynamoDB `exec-aide`**: Single-table design (MSG#, THD#, META#, CONV#, COORD# prefixes). GSI `by-date` for daily digest queries. 90-day TTL (7-day for COORD).
- **DynamoDB `exec-aide`**: Single-table design (MSG#, THD#, META#, CONV#, COORD#, TASK# prefixes). GSI `by-date` for daily digest queries. 90-day TTL (7-day for COORD).
### Conversation Tools
@ -35,10 +39,18 @@ The conversation Lambda has access to these tools:
| `trigger_daily_digest` | Send a digest on demand |
| `search_inbox` | Full Gmail search with query syntax |
| `coordinate_with_user` | Start a coordination thread with someone in a channel |
| `create_task` | Add a task to Adam's to-do list |
| `list_tasks` | Show open tasks |
| `complete_task` | Mark a task as done |
| `delete_task` | Remove a task |
| `create_reminder` | Schedule a one-shot reminder DM at a specific time |
| `list_calendar_events` | List upcoming Google Calendar events |
| `create_calendar_event` | Create a new calendar event |
| `check_availability` | Check free/busy status for a time range |
### CDK Constructs
- `lib/constructs/email-pipeline.ts` — DynamoDB table, all three Lambda functions, EventBridge schedules
- `lib/constructs/email-pipeline.ts` — DynamoDB table, all four Lambda functions (fetch-classify, daily-digest, conversation, reminder), EventBridge schedules
- `lib/constructs/socket-mode.ts` — VPC, ECS cluster, Fargate service, ECR repo (image built automatically via `ContainerImage.fromAsset()`)
## Classification Rules
@ -55,19 +67,21 @@ The conversation Lambda has access to these tools:
## Prerequisites
- AWS CDK CLI (`npm install -g aws-cdk`)
- Node.js 18+, Python 3.12
- Node.js 22+, Python 3.12
- Docker (for Lambda bundling and Fargate image builds)
- Gmail API enabled in Google Cloud Console (project: `exec-aide`)
- Gmail API enabled in Google Cloud Console (project ID: `332395266465`)
- Google Calendar API enabled in Google Cloud Console (same project)
- Slack app "Exec Aide" with Socket Mode enabled
## One-Time Setup
### 1. Gmail OAuth
### 1. Google OAuth (Gmail + Calendar)
```bash
pip install google-auth-oauthlib
python scripts/get_gmail_token.py --client-secrets-file path/to/client_secret.json
# Authorize as adam@seahavenind.com, copy output to Secrets Manager
python scripts/get_gmail_token.py --client-secrets-file ~/.ssh/client_secret_332395266465-mn83qrihoq45njpaiu448rsml1v02f5r.apps.googleusercontent.com.json
# Authorize as adam@seahavenind.com (grants gmail.readonly + calendar scopes)
# Copy output JSON to Secrets Manager
```
### 2. Secrets Manager
@ -90,6 +104,8 @@ python scripts/get_gmail_token.py --client-secrets-file path/to/client_secret.js
## Deploy
Pushes to `main` trigger the CI/CD pipeline automatically. For manual deployment:
```bash
npm install
cdk deploy

11
buildspec.yml Normal file
View file

@ -0,0 +1,11 @@
version: 0.2
phases:
install:
runtime-versions:
nodejs: 22
commands:
- npm ci
build:
commands:
- npx cdk deploy --require-approval never

View file

@ -150,6 +150,31 @@ export class EmailPipelineConstruct extends Construct {
sourceArn: `arn:aws:scheduler:${region}:${account}:schedule/default/${schedule.name}`,
});
// ── Reminder Lambda ──────────────────────────────────────
const reminder = new PythonFunction(this, 'Reminder', {
functionName: 'exec-aide-reminder',
entry: path.join(__dirname, '../../src'),
index: 'reminder/app.py',
handler: 'lambda_handler',
runtime: lambda.Runtime.PYTHON_3_12,
architecture: lambda.Architecture.ARM_64,
memorySize: 128,
timeout: cdk.Duration.seconds(30),
environment: lambdaEnv,
logRetention: logs.RetentionDays.TWO_MONTHS,
});
this.table.grantReadData(reminder);
reminder.addToRolePolicy(secretsReadPolicy);
reminder.addToRolePolicy(ssmPolicy);
const reminderSchedulerRole = new iam.Role(this, 'ReminderSchedulerRole', {
roleName: 'exec-aide-reminder-scheduler',
assumedBy: new iam.ServicePrincipal('scheduler.amazonaws.com'),
});
reminder.grantInvoke(reminderSchedulerRole);
// ── Conversation Lambda ───────────────────────────────────
const conversation = new PythonFunction(this, 'Conversation', {
@ -161,7 +186,11 @@ export class EmailPipelineConstruct extends Construct {
architecture: lambda.Architecture.ARM_64,
memorySize: 512,
timeout: cdk.Duration.seconds(180),
environment: lambdaEnv,
environment: {
...lambdaEnv,
REMINDER_FN_ARN: reminder.functionArn,
REMINDER_SCHEDULER_ROLE_ARN: reminderSchedulerRole.roleArn,
},
logRetention: logs.RetentionDays.TWO_MONTHS,
});
@ -176,6 +205,16 @@ export class EmailPipelineConstruct extends Construct {
`arn:aws:bedrock:${region}:${account}:inference-profile/us.anthropic.*`,
],
}));
conversation.addToRolePolicy(new iam.PolicyStatement({
actions: ['scheduler:CreateSchedule', 'scheduler:DeleteSchedule'],
resources: [
`arn:aws:scheduler:${region}:${account}:schedule/default/exec-aide-reminder-*`,
],
}));
conversation.addToRolePolicy(new iam.PolicyStatement({
actions: ['iam:PassRole'],
resources: [reminderSchedulerRole.roleArn],
}));
dailyDigest.grantInvoke(conversation);
this.conversationFn = conversation;

View file

@ -64,7 +64,7 @@ def _get_coordination(channel, thread_ts):
def _dispatch_conversation(text, thread_ts, channel, say, is_dm):
placeholder_ts = None
if is_dm:
result = say(text="Thinking...", thread_ts=thread_ts)
result = say(text="Thinking...")
placeholder_ts = result["ts"]
lambda_client.invoke(
@ -143,7 +143,7 @@ def handle_message(event, say):
if not text:
return
thread_ts = event.get("thread_ts") or event["ts"]
thread_ts = None
_dispatch_conversation(text, thread_ts, event["channel"], say, is_dm=True)

172
pipeline.yaml Normal file
View file

@ -0,0 +1,172 @@
AWSTemplateFormatVersion: "2010-09-09"
Description: CI/CD pipeline — CodePipeline + CodeBuild for CDK deployments
Parameters:
GitHubOwner:
Type: String
Default: Sea-Haven-Industries
GitHubRepo:
Type: String
Default: exec-aide
GitHubBranch:
Type: String
Default: main
ConnectionArn:
Type: String
Description: CodeConnections ARN for GitHub
Default: arn:aws:codeconnections:us-east-1:328440206208:connection/52bc9d0e-1088-43bc-9394-2c513ed10bc6
Resources:
ArtifactBucket:
Type: AWS::S3::Bucket
Properties:
BucketName: exec-aide-pipeline-artifacts
LifecycleConfiguration:
Rules:
- Id: expire-artifacts
Status: Enabled
ExpirationInDays: 30
Tags:
- Key: Purpose
Value: pipeline-artifacts
- Key: ManagedBy
Value: !Ref AWS::StackName
CodeBuildRole:
Type: AWS::IAM::Role
Properties:
RoleName: exec-aide-codebuild
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: codebuild.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: codebuild-permissions
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- logs:CreateLogGroup
- logs:CreateLogStream
- logs:PutLogEvents
Resource: !Sub "arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:/aws/codebuild/exec-aide-build*"
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:GetBucketLocation
Resource:
- !GetAtt ArtifactBucket.Arn
- !Sub "${ArtifactBucket.Arn}/*"
- Effect: Allow
Action: sts:AssumeRole
Resource:
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-deploy-role-${AWS::AccountId}-${AWS::Region}"
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-lookup-role-${AWS::AccountId}-${AWS::Region}"
- !Sub "arn:aws:iam::${AWS::AccountId}:role/cdk-hnb659fds-cfn-exec-role-${AWS::AccountId}-${AWS::Region}"
CodeBuildProject:
Type: AWS::CodeBuild::Project
Properties:
Name: exec-aide-build
Description: Build and deploy exec-aide CDK stack
ServiceRole: !GetAtt CodeBuildRole.Arn
Artifacts:
Type: CODEPIPELINE
Environment:
Type: ARM_CONTAINER
ComputeType: BUILD_GENERAL1_SMALL
Image: aws/codebuild/amazonlinux-aarch64-standard:3.0
EnvironmentVariables: []
PrivilegedMode: false
Source:
Type: CODEPIPELINE
BuildSpec: buildspec.yml
TimeoutInMinutes: 10
PipelineRole:
Type: AWS::IAM::Role
Properties:
RoleName: exec-aide-pipeline
AssumeRolePolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Principal:
Service: codepipeline.amazonaws.com
Action: sts:AssumeRole
Policies:
- PolicyName: pipeline-permissions
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- codeconnections:UseConnection
Resource: !Ref ConnectionArn
- Effect: Allow
Action:
- s3:GetObject
- s3:PutObject
- s3:GetBucketLocation
Resource:
- !GetAtt ArtifactBucket.Arn
- !Sub "${ArtifactBucket.Arn}/*"
- Effect: Allow
Action:
- codebuild:StartBuild
- codebuild:BatchGetBuilds
Resource: !GetAtt CodeBuildProject.Arn
Pipeline:
Type: AWS::CodePipeline::Pipeline
Properties:
Name: exec-aide-pipeline
RoleArn: !GetAtt PipelineRole.Arn
ArtifactStore:
Type: S3
Location: !Ref ArtifactBucket
Stages:
- Name: Source
Actions:
- Name: GitHub
ActionTypeId:
Category: Source
Owner: AWS
Provider: CodeStarSourceConnection
Version: "1"
Configuration:
ConnectionArn: !Ref ConnectionArn
FullRepositoryId: !Sub "${GitHubOwner}/${GitHubRepo}"
BranchName: !Ref GitHubBranch
DetectChanges: true
OutputArtifacts:
- Name: SourceOutput
- Name: Build
Actions:
- Name: CDKDeploy
ActionTypeId:
Category: Build
Owner: AWS
Provider: CodeBuild
Version: "1"
Configuration:
ProjectName: !Ref CodeBuildProject
InputArtifacts:
- Name: SourceOutput
OutputArtifacts:
- Name: BuildOutput
Outputs:
PipelineName:
Value: !Ref Pipeline
PipelineUrl:
Value: !Sub "https://${AWS::Region}.console.aws.amazon.com/codesuite/codepipeline/pipelines/${Pipeline}/view"
ArtifactBucketName:
Value: !Ref ArtifactBucket

View file

@ -13,7 +13,10 @@ import json
from google_auth_oauthlib.flow import InstalledAppFlow
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar",
]
def main():

View file

@ -37,8 +37,16 @@ unnecessary preamble. Use bullet points for lists of emails.
When referencing emails, always include the sender name and subject line. \
For time references, use relative terms (e.g., "2 hours ago", "yesterday").
If Adam asks about something outside your current capabilities (calendar, \
tasks, etc.), acknowledge it and let him know it's not available yet.
You can manage Adam's task list — create tasks, list them, mark them \
complete, or delete them. You can also set reminders that will send a Slack \
DM at a specified time. When Adam asks to be reminded of something, use \
create_reminder with the appropriate date/time (default timezone: \
America/New_York).
You have access to Adam's Google Calendar. You can view upcoming events, \
check availability, and create new events. Always confirm event details \
(time, attendees, title) with Adam before creating. Default timezone: \
America/New_York. Use ISO 8601 format for all times sent to calendar tools.
Speak naturally and casually, like a capable coworker - not a bot or AI. \
Keep responses conversational.

View file

@ -7,6 +7,7 @@ import boto3
from shared.dynamo import (
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
@ -183,6 +184,185 @@ TOOLS = [
},
}
},
{
"toolSpec": {
"name": "create_task",
"description": "Create a new task/to-do item for Adam.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Task description.",
},
"due_date": {
"type": "string",
"description": "Optional due date in YYYY-MM-DD format.",
},
},
"required": ["title"],
}
},
}
},
{
"toolSpec": {
"name": "list_tasks",
"description": "List Adam's tasks. Can filter by status (open or completed).",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "Filter by status: 'open' or 'completed'. Omit for all tasks.",
},
},
}
},
}
},
{
"toolSpec": {
"name": "complete_task",
"description": "Mark a task as completed.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "The task ID (ULID) to mark as completed.",
},
},
"required": ["task_id"],
}
},
}
},
{
"toolSpec": {
"name": "delete_task",
"description": "Delete a task permanently.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "The task ID (ULID) to delete.",
},
},
"required": ["task_id"],
}
},
}
},
{
"toolSpec": {
"name": "create_reminder",
"description": "Set a reminder that will send Adam a Slack DM at a specific time.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The reminder message text.",
},
"remind_at": {
"type": "string",
"description": "When to send the reminder in ISO 8601 format (e.g., 2026-05-05T09:00:00). Timezone is America/New_York.",
},
},
"required": ["text", "remind_at"],
}
},
}
},
{
"toolSpec": {
"name": "get_calendar_events",
"description": "Get Adam's upcoming calendar events for a date range.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"start_date": {
"type": "string",
"description": "Start date in YYYY-MM-DD format. Defaults to today.",
},
"end_date": {
"type": "string",
"description": "End date in YYYY-MM-DD format. Defaults to start_date + 1 day.",
},
"max_results": {
"type": "integer",
"description": "Maximum events to return. Defaults to 10.",
},
},
}
},
}
},
{
"toolSpec": {
"name": "create_calendar_event",
"description": "Create a new event on Adam's Google Calendar. Always confirm details with Adam before calling this.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"summary": {
"type": "string",
"description": "Event title.",
},
"start_time": {
"type": "string",
"description": "Start time in ISO 8601 (e.g., 2026-05-05T10:00:00).",
},
"end_time": {
"type": "string",
"description": "End time in ISO 8601 (e.g., 2026-05-05T11:00:00).",
},
"attendees": {
"type": "array",
"items": {"type": "string"},
"description": "Email addresses of attendees.",
},
"description": {
"type": "string",
"description": "Event description or notes.",
},
},
"required": ["summary", "start_time", "end_time"],
}
},
}
},
{
"toolSpec": {
"name": "check_availability",
"description": "Check Adam's calendar availability (free/busy) for a time range.",
"inputSchema": {
"json": {
"type": "object",
"properties": {
"start_time": {
"type": "string",
"description": "Range start in ISO 8601 format.",
},
"end_time": {
"type": "string",
"description": "Range end in ISO 8601 format.",
},
},
"required": ["start_time", "end_time"],
}
},
}
},
]
@ -222,6 +402,14 @@ def dispatch_tool(tool_name, tool_input):
"trigger_daily_digest": _handle_trigger_digest,
"search_inbox": _handle_search_inbox,
"coordinate_with_user": _handle_coordinate,
"create_task": _handle_create_task,
"list_tasks": _handle_list_tasks,
"complete_task": _handle_complete_task,
"delete_task": _handle_delete_task,
"create_reminder": _handle_create_reminder,
"get_calendar_events": _handle_get_calendar_events,
"create_calendar_event": _handle_create_calendar_event,
"check_availability": _handle_check_availability,
}
handler = handlers.get(tool_name)
if not handler:
@ -328,11 +516,19 @@ def _handle_thread_detail(inputs):
def _handle_trigger_digest(inputs):
_get_lambda_client().invoke(
resp = _get_lambda_client().invoke(
FunctionName="exec-aide-daily-digest",
InvocationType="Event",
InvocationType="RequestResponse",
)
return {"status": "triggered", "message": "Daily digest has been triggered and will arrive shortly."}
payload = json.loads(resp["Payload"].read())
return {
"status": "sent",
"high_priority": payload.get("high", 0),
"bypassed_work_orders": payload.get("bypassed", 0),
"unanswered_threads": payload.get("unanswered", 0),
"normal": payload.get("normal", 0),
"low": payload.get("low", 0),
}
def _handle_search_inbox(inputs):
@ -386,3 +582,129 @@ def _handle_coordinate(inputs):
"status": "coordinating",
"message": "Thread started. I'll follow up when they respond.",
}
# ── Task & Reminder Handlers ──────────────────────────────────
def _handle_create_task(inputs):
task_id = create_task(inputs["title"], inputs.get("due_date"))
return {"task_id": task_id, "status": "created", "title": inputs["title"]}
def _handle_list_tasks(inputs):
status_filter = inputs.get("status")
tasks = get_tasks(status_filter)
return {
"count": len(tasks),
"tasks": [
{
"task_id": t["pk"].replace("TASK#", ""),
"title": t["title"],
"due_date": t.get("due_date", ""),
"status": t["status"],
"created_at": t.get("created_at", ""),
}
for t in tasks
],
}
def _handle_complete_task(inputs):
update_task_status(inputs["task_id"], "completed")
return {"task_id": inputs["task_id"], "status": "completed"}
def _handle_delete_task(inputs):
delete_task(inputs["task_id"])
return {"task_id": inputs["task_id"], "status": "deleted"}
def _handle_create_reminder(inputs):
import ulid as _ulid
scheduler = boto3.client("scheduler")
schedule_name = f"exec-aide-reminder-{_ulid.new()}"
remind_at = inputs["remind_at"]
scheduler.create_schedule(
Name=schedule_name,
ScheduleExpression=f"at({remind_at})",
ScheduleExpressionTimezone="America/New_York",
FlexibleTimeWindow={"Mode": "OFF"},
Target={
"Arn": os.environ["REMINDER_FN_ARN"],
"RoleArn": os.environ["REMINDER_SCHEDULER_ROLE_ARN"],
"Input": json.dumps({"text": inputs["text"]}),
},
ActionAfterCompletion="DELETE",
)
return {"status": "scheduled", "remind_at": remind_at, "text": inputs["text"]}
# ── Calendar Handlers ──────────────────────────────────────────
def _handle_get_calendar_events(inputs):
from datetime import timedelta
from shared.calendar import get_calendar_service, list_events
start_date = inputs.get("start_date") or _today()
end_date = inputs.get("end_date")
if not end_date:
d = datetime.strptime(start_date, "%Y-%m-%d")
end_date = (d + timedelta(days=1)).strftime("%Y-%m-%d")
time_min = f"{start_date}T00:00:00-04:00"
time_max = f"{end_date}T23:59:59-04:00"
max_results = inputs.get("max_results", 10)
service = get_calendar_service()
events = list_events(service, time_min, time_max, max_results)
return {
"count": len(events),
"events": [
{
"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", ""),
"attendees": [a.get("email", "") for a in e.get("attendees", [])],
"location": e.get("location", ""),
"status": e.get("status", ""),
}
for e in events
],
}
def _handle_create_calendar_event(inputs):
from shared.calendar import get_calendar_service, create_event
service = get_calendar_service()
event = create_event(
service,
summary=inputs["summary"],
start={"dateTime": inputs["start_time"], "timeZone": "America/New_York"},
end={"dateTime": inputs["end_time"], "timeZone": "America/New_York"},
attendees=inputs.get("attendees"),
description=inputs.get("description"),
)
return {
"status": "created",
"event_id": event["id"],
"summary": event.get("summary", ""),
"html_link": event.get("htmlLink", ""),
}
def _handle_check_availability(inputs):
from shared.calendar import get_calendar_service, check_availability
service = get_calendar_service()
busy = check_availability(service, inputs["start_time"], inputs["end_time"])
return {
"busy_slots": busy,
"is_free": len(busy) == 0,
}

0
src/reminder/__init__.py Normal file
View file

25
src/reminder/app.py Normal file
View file

@ -0,0 +1,25 @@
import logging
from shared.secrets import get_config, get_slack_token
from shared.slack import post_message
logger = logging.getLogger()
logger.setLevel(logging.INFO)
def lambda_handler(event, context):
text = event.get("text", "Reminder (no details provided)")
config = get_config()
token = get_slack_token()
import requests
resp = requests.post(
"https://slack.com/api/conversations.open",
headers={"Authorization": f"Bearer {token}"},
json={"users": config["adam_slack_user_id"]},
)
resp.raise_for_status()
channel = resp.json()["channel"]["id"]
post_message(channel, f":bell: *Reminder:* {text}", token)
logger.info("Reminder sent: %s", text)

View file

@ -1,3 +1,4 @@
google-api-python-client>=2.195.0
google-auth>=2.50.0
python-ulid>=2.0
requests>=2.33.1

72
src/shared/calendar.py Normal file
View file

@ -0,0 +1,72 @@
import logging
import google.auth.transport.requests
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
from shared.secrets import get_gmail_oauth, save_gmail_tokens
logger = logging.getLogger(__name__)
_service = None
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar",
]
def get_calendar_service():
global _service
oauth = get_gmail_oauth()
creds = Credentials(
token=oauth.get("access_token"),
refresh_token=oauth["refresh_token"],
token_uri="https://oauth2.googleapis.com/token",
client_id=oauth["client_id"],
client_secret=oauth["client_secret"],
scopes=SCOPES,
)
if not creds.valid:
creds.refresh(google.auth.transport.requests.Request())
save_gmail_tokens(
creds.token,
creds.expiry.isoformat() if creds.expiry else "",
)
_service = build("calendar", "v3", credentials=creds, cache_discovery=False)
return _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()
return result.get("items", [])
def create_event(service, summary, start, end, attendees=None, description=None):
body = {
"summary": summary,
"start": start,
"end": end,
}
if description:
body["description"] = description
if attendees:
body["attendees"] = [{"email": a} for a in attendees]
return service.events().insert(calendarId="primary", body=body).execute()
def check_availability(service, time_min, time_max):
body = {
"timeMin": time_min,
"timeMax": time_max,
"items": [{"id": "primary"}],
}
result = service.freebusy().query(body=body).execute()
return result["calendars"]["primary"]["busy"]

View file

@ -3,6 +3,7 @@ import time
from datetime import datetime, timezone
import boto3
import ulid
from botocore.exceptions import ClientError
_table = None
@ -205,3 +206,62 @@ def get_unanswered_threads(threshold_hours):
item for item in items
if datetime.fromisoformat(item["unanswered_since"]).timestamp() < cutoff
]
# ── Tasks ──────────────────────────────────────────────────────
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(),
})
return task_id
def get_tasks(status_filter=None):
scan_kwargs = {
"FilterExpression": "begins_with(pk, :prefix)",
"ExpressionAttributeValues": {":prefix": "TASK#"},
}
if status_filter:
scan_kwargs["FilterExpression"] += " AND #s = :status"
scan_kwargs["ExpressionAttributeNames"] = {"#s": "status"}
scan_kwargs["ExpressionAttributeValues"][":status"] = status_filter
items = []
resp = _get_table().scan(**scan_kwargs)
items.extend(resp.get("Items", []))
while "LastEvaluatedKey" in resp:
scan_kwargs["ExclusiveStartKey"] = resp["LastEvaluatedKey"]
resp = _get_table().scan(**scan_kwargs)
items.extend(resp.get("Items", []))
items.sort(key=lambda x: x["pk"])
return items
def update_task_status(task_id, status):
update_expr = "SET #s = :status, ttl = :ttl"
expr_values = {":status": status, ":ttl": _ttl_90_days()}
if status == "completed":
update_expr += ", completed_at = :now"
expr_values[":now"] = datetime.now(timezone.utc).isoformat()
_get_table().update_item(
Key={"pk": f"TASK#{task_id}", "sk": "TASK"},
UpdateExpression=update_expr,
ExpressionAttributeNames={"#s": "status"},
ExpressionAttributeValues=expr_values,
)
def delete_task(task_id):
_get_table().delete_item(Key={"pk": f"TASK#{task_id}", "sk": "TASK"})

View file

@ -13,7 +13,10 @@ logger = logging.getLogger(__name__)
_service = None
SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"]
SCOPES = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/calendar",
]
def get_authenticated_service():