From 5a93c35b1893833d6655713dbdac7afdc52d1ca1 Mon Sep 17 00:00:00 2001 From: wolf-demon Date: Tue, 15 Sep 2026 09:50:52 +0100 Subject: [PATCH] Add controlled FAQ auto-replies with test mode and durable sending limits --- .env.example | 3 + README.md | 5 +- compose.yml | 1 + docs/auto-replies.md | 39 ++++++++++ docs/migration.md | 6 +- src/GuestOps.Api/AutoReplies.cs | 104 +++++++++++++++++++++++++ src/GuestOps.Api/AutoReplyEndpoints.cs | 36 +++++++++ src/GuestOps.Api/GoogleMailbox.cs | 7 ++ src/GuestOps.Api/Models.cs | 15 ++++ src/GuestOps.Api/Program.cs | 8 ++ src/GuestOps.Api/ReplyDelivery.cs | 7 +- src/GuestOps.Api/Store.cs | 14 ++++ src/GuestOps.Worker/Program.cs | 26 +++++++ tests/GuestOps.Tests/AutoReplyTests.cs | 71 +++++++++++++++++ tests/GuestOps.Tests/Program.cs | 14 +++- tests/production_smoke.py | 3 + web/src/AutomationPage.tsx | 23 ++++++ web/src/ReplyActions.tsx | 10 ++- web/src/api.ts | 4 +- web/src/main.tsx | 9 ++- 20 files changed, 389 insertions(+), 16 deletions(-) create mode 100644 docs/auto-replies.md create mode 100644 src/GuestOps.Api/AutoReplies.cs create mode 100644 src/GuestOps.Api/AutoReplyEndpoints.cs create mode 100644 tests/GuestOps.Tests/AutoReplyTests.cs create mode 100644 web/src/AutomationPage.tsx diff --git a/.env.example b/.env.example index af12d83..d385972 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,6 @@ GUESTOPS_WORKER_IMAGE=guestops-worker:local # Private NMI configuration; leave the empty example until sandbox setup. PAYMENTS_CONFIG_FILE_HOST=./deploy/payments.example.json + +# Enable only after Google delivery and FAQ test-mode acceptance. +AUTO_REPLY_ENABLE_LIVE=false diff --git a/README.md b/README.md index 6181401..64e9e1a 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ A Linux-hosted hotel email workspace, developed separately from the Windows Gues - Google OAuth connection and a separate Gmail import/delivery worker. The first import covers seven days of inbox messages; paginated checkpoints and duplicate suppression survive restarts. - Windows-independent booking model, validation, email cleaning, JSON extraction and secret redaction migrated from the hardened desktop code. OHIP exact reservation lookup, internal notes and owner-approved stay-date changes are implemented with durable review and read-only reconciliation; writes are off by default. - Owner-reviewed NMI invoice creation, tenant-specific merchant configuration and read-only status/recovery checks. Creation may email the customer a hosted payment link through NMI; it is off by default. +- Controlled FAQ auto-replies: exact plain-text questions, owner-reviewed answers, test mode, daily quotas and thread/knowledge rechecks. Live mode defaults off. - Docker image builds, private MongoDB configuration and an Nginx HTTPS example for the Debian sandbox. ## Explicit limits -Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Automatic sending, wider PMS workflows, direct payment URLs in replies, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator. +Real email is sent only after server configuration, Google send consent, hotel-owner opt-in and explicit staff approval of a saved reply. Broad natural-language automatic sending, wider PMS workflows, direct payment URLs in replies, staff invitation/password-reset UI, attachments and complete Gmail-thread aggregation are follow-on work. There is no public registration endpoint. Initial hotel owners are provisioned by the server administrator. The Google integration needs OAuth credentials and a sandbox mailbox before its live behaviour can be accepted. Automated tests do not access Gmail or a hotel system. An integration being implemented is not a claim of Google verification or production readiness. @@ -59,6 +60,6 @@ cd web && npm ci && npm run build Set `MONGO_TEST_URI` to an isolated MongoDB server and `TEST_API_URL=http://127.0.0.1:5180` with a preview API running to enable database and HTTP integration checks. The suite creates and drops only its own randomly named `guestops_test_*` database. CI runs both integrations and builds both Linux images. -See [NMI payment setup and recovery](docs/payments.md), [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md). +See [controlled FAQ automation](docs/auto-replies.md), [NMI payment setup and recovery](docs/payments.md), [OHIP reservation setup and recovery](docs/pms.md), [AI drafts and reply delivery setup](docs/replies.md), [migration status](docs/migration.md) and [deployment guide](docs/deployment.md). diff --git a/compose.yml b/compose.yml index c9dd240..a69ced6 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,7 @@ x-app-env: &app-env PublicUrl: https://sandbox-guestops.futuresens.co.uk Google__ClientId: ${GOOGLE_CLIENT_ID:-} Google__ClientSecret: ${GOOGLE_CLIENT_SECRET:-} + AutoReply__EnableLive: ${AUTO_REPLY_ENABLE_LIVE:-false} Google__EnableSending: ${GOOGLE_ENABLE_SENDING:-false} Logging__LogLevel__Default: Warning Logging__LogLevel__Microsoft.AspNetCore.Hosting.Diagnostics: Warning diff --git a/docs/auto-replies.md b/docs/auto-replies.md new file mode 100644 index 0000000..1579dbe --- /dev/null +++ b/docs/auto-replies.md @@ -0,0 +1,39 @@ +# Controlled FAQ auto-replies + +The first automatic-response implementation supports seven exact English questions about check-in, check-out, parking, breakfast, Wi-Fi and the hotel's address. An owner maps each question to approved hotel knowledge. Responses use that answer verbatim plus the hotel signature; no AI classification or rewriting occurs. + +## Start with test mode + +Open **FAQ automation**, choose a question and approved answer, enable the rule and save it. Use **Try a question** to inspect the content match without sending. This tester does not simulate recipient, MIME, Gmail-thread or quota checks. Use **Test mode** with a connected sandbox mailbox to evaluate newly received messages and inspect the actual incoming-message results. Test matches do not create deliveries or consume quotas. + +Matching tolerates case, whitespace and trailing question marks/full stops. It does not remove greetings, signatures, quoted text or additional requests. Subjects must be blank, one of the supported questions, or a short permitted heading such as `Question`, `Quick question`, `Parking question`, `Check-in`, `Breakfast` or `WiFi`. Unsupported content stays with staff. + +The first version requires a top-level plain-text MIME message. HTML and multipart messages are held for staff because the alternate body may contain context missing from plain text. Attachments, multiple recipients, CC/BCC, reply threads, mailing lists, automated-message headers, mismatched Reply-To and no-reply senders are also excluded. Old imported messages without the new eligibility flag cannot qualify. + +## Enable live mode after acceptance + +Keep `AUTO_REPLY_ENABLE_LIVE=false` in the deployment `.env` until the Google send/reconciliation workflow and FAQ test-mode results have been accepted. Then set it true and restart both API and worker. Gmail sending must be configured, the hotel must enable staff sending, and the mailbox must have send consent. Finally, the owner explicitly confirms test-mode acceptance and selects **Enable live replies**. + +All modes default to Off. Every mode change creates a new activation boundary and invalidates previous queued automatic approvals. Only untouched messages received after activation and within the last 24 hours are eligible. Switching Test to Live does not send replies to previous test matches. The evaluation worker runs about every 30 seconds; the existing delivery worker handles approved outgoing messages. + +## Limits and rechecks + +MongoDB uniquely reserves one automatic reply per Gmail thread, one per recipient per UTC day across the hotel's mailboxes, and at most 20 daily hotel slots. Slots are reserved before queueing and are not recycled after rejection or failure. Deliveries cannot carry their approval into a later UTC day. Limits concern GuestOps automation, not messages sent manually in Gmail. UTC boundaries are not rolling 24-hour windows. + +The worker rechecks hotel mode, activation, server enablement, rule version, approved knowledge version and exact reply text before sending. It reads the current Gmail thread and requires the original inbox message to be its only message. A rule/answer edit or a new thread reply therefore stops queued automation. Changes made after the final read and provider submission cannot be eliminated atomically; a message already submitted to Gmail cannot be recalled by the stop control. + +Automatic MIME includes `Auto-Submitted: auto-replied` and `X-Auto-Response-Suppress: All`, following the loop-prevention guidance in [RFC 3834](https://www.rfc-editor.org/rfc/rfc3834). This does not guarantee cooperation from every mail system. + +## Staff handover and recovery + +Non-matches remain in the inbox with an evaluation reason. Existing staff edits, drafts and deliveries are never overwritten. Editing an approved knowledge answer invalidates its FAQ rules until an owner reviews and saves them again. + +A rejected automatic delivery was stopped before Gmail submission. **Return to staff review** releases its draft for manual review and preserves the rejected approval evidence. Uncertain outcomes cannot be released or automatically replayed; use the existing read-only Gmail Sent verification. Turning automation off stops pending automatic deliveries at the next pre-send check. Manual staff-approved replies remain governed by their own controls. + +The database retains thread/recipient/quota reservations and evaluation evidence. The UI shows evaluated messages among the latest 500 inbox records. An evaluation interrupted before queueing can consume a slot without sending; it is deliberately not automatically retried. + +## Acceptance and follow-on work + +Automated tests use fake provider handlers and MongoDB; they never send live emails. They cover exact matching, exclusions, tenant boundaries, changed answers, concurrent evaluation, quotas, rule/epoch invalidation, Gmail-thread changes and uncertain delivery. Live acceptance remains pending. + +Test allowed questions and exclusions with your sandbox mailbox, verify duplicate prevention across restarts, inspect the actual received email, and exercise the stop control before enabling a hotel. Broad natural-language matching, multilingual questions, greetings/signature stripping, HTML/multipart equivalence and higher throughput are follow-on work requiring representative evaluation. PMS actions, payment requests and complex guest issues remain staff workflows. diff --git a/docs/migration.md b/docs/migration.md index 347eefe..800a66a 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -28,12 +28,16 @@ Implemented exact confirmation lookup, internal notes and owner-approved stay-da Implemented owner-reviewed invoice creation, unique payment references, customer-email approval, tenant-specific merchant configuration, partial/paid invoice status and read-only recovery after lost responses. The hosted payment link is delivered by NMI's invoice email; the published API does not guarantee a URL for insertion into GuestOps replies. Live sandbox acceptance remains pending. See [payment setup and limits](payments.md). +## Milestone 5: controlled FAQ auto-replies + +Implemented seven exact FAQ question rules, approved-answer version binding, test/live modes, durable daily quotas, Gmail thread rechecks and staff handover for rejected automatic replies. Plain-text messages only; broad natural-language matching is not claimed. Live Gmail and rule acceptance remains pending. See [automation setup and limits](auto-replies.md). + ## Remaining milestones 1. Test Google connection with a dedicated test mailbox; add provider fixture tests, disconnect/revocation, refresh failure recovery and full thread aggregation. 2. Add staff invitations, account recovery, granular roles, user preferences and an onboarding wizard. 3. Run a representative live AI draft evaluation, improve retrieval and evidence presentation, and approve the selected provider data-processing arrangements. -4. Extend the implemented durable reply queue with operator recovery tooling and guarded FAQ auto-replies after live acceptance. Preserve the rule that uncertain sends are never blindly replayed. +4. Extend the implemented durable reply queue with operator recovery tooling and broaden the controlled FAQ rules only after live acceptance. Preserve the rule that uncertain sends are never blindly replayed. 5. Validate the OHIP adapter against the property sandbox, extend supported PMS operations and validate NMI hosted invoices with the merchant sandbox. Add direct payment URLs only when a supported provider contract is available. Do not enable these by merely copying desktop settings or toggling a feature flag. Windows DPAPI settings must not be copied to Linux as usable credentials. Re-enter provider credentials through the server setup and reauthorize each mailbox. No existing hotel data has been transferred. diff --git a/src/GuestOps.Api/AutoReplies.cs b/src/GuestOps.Api/AutoReplies.cs new file mode 100644 index 0000000..c553bba --- /dev/null +++ b/src/GuestOps.Api/AutoReplies.cs @@ -0,0 +1,104 @@ +using System.Text.RegularExpressions; +namespace GuestOps.Web; +public sealed class AutoReplyRule:TenantDocument +{ + public string Question {get;set;}=""; + public string KnowledgeId {get;set;}=""; + public long KnowledgeVersion {get;set;} + public bool Enabled {get;set;} + public long Version {get;set;} + public string ApprovedBy {get;set;}=""; +} +public sealed class AutoReplyClaim:TenantDocument +{ + public string ThreadKey {get;set;}=""; + public string RecipientDay {get;set;}=""; + public string DaySlot {get;set;}=""; + public string ConversationId {get;set;}=""; +} +public sealed record AutoRuleInput(string Question,string KnowledgeId,bool Enabled,long Version); +public sealed record AutoModeInput(string Mode,long Version,bool AcceptanceConfirmed); +public sealed record AutoTestInput(string Subject,string Body); +public sealed record AutoDecision(bool Matches,string Reason,string RuleId="",string KnowledgeId="",string Body=""); +public static class FaqMatcher +{ + public static readonly string[] Questions=["What time is check in?","What time is check out?","Where can I park?","Is parking available?","What time is breakfast?","Do you have WiFi?","What is your address?"]; + public static string Normalize(string value)=>Regex.Replace((value??"").Trim().ToLowerInvariant(),@"\s+"," ").TrimEnd('?','.'); + public static bool SubjectAllowed(string subject)=>new[]{"", "question", "quick question", "parking", "parking question", "check in", "check-in", "check out", "check-out", "breakfast", "wifi", "wi-fi", "address"}.Contains(Normalize(subject))||Questions.Any(q=>Normalize(q)==Normalize(subject)); + public static bool Sensitive(string text)=>Regex.IsMatch(text,@"\b(cancel\w*|refund\w*|pay\w*|card\w*|allerg\w*|medical|emergency|injur\w*|complain\w*|charge\w*|chang\w*|disab\w*|accessible|safety|fire|police|lost|stolen|urgent|booking|reservation)\b",RegexOptions.IgnoreCase); + public static AutoDecision Evaluate(Conversation message,IEnumerable rules,IEnumerable knowledge) + { + if(message.Body.Length>200||!SubjectAllowed(message.Subject)||Sensitive(message.Subject+" "+message.Body))return new(false,"This message needs staff handling."); + var question=Normalize(message.Body); + if(!Questions.Any(q=>Normalize(q)==question))return new(false,"The complete message does not match a supported FAQ question."); + var matches=rules.Where(r=>r.HotelId==message.HotelId&&r.Enabled&&Normalize(r.Question)==question).ToArray(); + if(matches.Length!=1)return new(false,"Exactly one enabled rule is required."); + var rule=matches[0];var answer=knowledge.SingleOrDefault(k=>k.Id==rule.KnowledgeId&&k.HotelId==message.HotelId); + if(answer?.Approved!=true||answer.Version!=rule.KnowledgeVersion||string.IsNullOrWhiteSpace(answer.Answer))return new(false,"The approved answer changed or is unavailable. Review the rule again."); + return new(true,"Exact FAQ match; the approved answer is used without AI rewriting.",rule.Id,answer.Id,answer.Answer); + } + public static bool HeadersEligible(System.Text.Json.JsonElement payload,string mailboxEmail) + { + if(!payload.TryGetProperty("headers",out var headers))return false; + string[] Values(string name)=>headers.EnumerateArray().Where(h=>h.GetProperty("name").GetString()!.Equals(name,StringComparison.OrdinalIgnoreCase)).Select(h=>h.GetProperty("value").GetString()??"").ToArray(); + string One(string name)=>Values(name) is var values&&values.Length==1?values[0]:""; + bool Absent(string name)=>Values(name).Length==0; + bool Attachment(System.Text.Json.JsonElement p)=>p.TryGetProperty("filename",out var f)&&f.GetString()?.Length>0||p.TryGetProperty("body",out var body)&&body.TryGetProperty("attachmentId",out _)||p.TryGetProperty("parts",out var parts)&&parts.EnumerateArray().Any(Attachment); + var from=ReplyMime.Address(One("From"));var reply=One("Reply-To"); + return payload.TryGetProperty("mimeType",out var mime)&&mime.GetString()=="text/plain"&&from.Length>0&&from!=mailboxEmail&&ReplyMime.Address(One("To"))==mailboxEmail&&Absent("Cc")&&Absent("Bcc")&&Absent("In-Reply-To")&&Absent("References")&&Absent("List-Id")&&Absent("Precedence")&&Absent("X-Auto-Response-Suppress")&&(Absent("Auto-Submitted")||One("Auto-Submitted").Equals("no",StringComparison.OrdinalIgnoreCase))&&(Absent("Reply-To")||ReplyMime.Address(reply)==from)&&One("Return-Path").Trim()!="<>"&&!Attachment(payload)&&!Regex.IsMatch(from,@"(^|[._-])(no.?reply|mailer.?daemon|postmaster)([.@_-]|$)",RegexOptions.IgnoreCase); + } +} +public sealed class AutoReplyWork(IStore store,IConfiguration config) +{ + public bool LiveConfigured=>config.GetValue("AutoReply:EnableLive"); + public async Task Test(string hotel,string subject,string body)=>FaqMatcher.Evaluate(new Conversation{HotelId=hotel,Subject=subject,Body=body},await store.List(hotel),await store.List(hotel)); + public async Task Process(Conversation message) + { + var hotel=await store.Get(message.HotelId,message.HotelId); + if(hotel==null||hotel.AutoReplyMode=="Off"||message.AutoReplyCheckedAt!=null)return; + var box=await store.Get(message.HotelId,message.MailboxId); + AutoDecision result; + if(!message.AutoReplyHeadersEligible||box?.Status!="Connected"||message.Delivery!=null||message.Version!=0||message.Status!="NeedsAttention"||message.Draft.Length>0||message.ReceivedAtDateTime.UtcNow.AddMinutes(5))result=new(false,"Message metadata, age or existing staff work requires manual handling."); + else result=await Test(message.HotelId,message.Subject,message.Body); + var version=message.Version;message.AutoReplyCheckedAt=DateTime.UtcNow;message.AutoReplyDetail=result.Reason;message.AutoReplyMatched=result.Matches; + message.Version++;if(!await store.Replace(message.HotelId,message.Id,version,message))return;version=message.Version; + if(result.Matches&&hotel.AutoReplyMode=="Live") + { + if(!LiveConfigured||!hotel.StaffSendingEnabled||box?.CanSend!=true){message.AutoReplyDetail="Automatic sending is disabled by server or mailbox controls.";} + else + { + var rule=(await store.Get(message.HotelId,result.RuleId))!; + message.Delivery=new Delivery{Automatic=true,AutoDay=DateTime.UtcNow.ToString("yyyy-MM-dd"),AutoRuleId=rule.Id,AutoRuleVersion=rule.Version,AutoKnowledgeId=rule.KnowledgeId,AutoKnowledgeVersion=rule.KnowledgeVersion,AutoEpoch=hotel.AutoReplyEpoch,Recipient=message.ReplyAddress,Body=result.Body+"\n\n"+hotel.Signature,ApprovedBy=rule.ApprovedBy}; + bool valid=await CanDeliver(store,config,message); + try{_=ReplyMime.Build(message,box!);}catch{valid=false;} + if(valid&&await Claim(message)){message.Draft=message.Delivery.Body;message.DraftSources=[result.KnowledgeId];message.AutoReplyDetail="Queued the approved FAQ answer for automatic delivery.";} + else{message.Delivery=null;message.AutoReplyDetail="Automatic reply was held by current controls, a changed rule, or delivery limits.";} + } + } + else if(result.Matches)message.AutoReplyDetail="Test match only: "+result.Reason; + message.Version++;await store.Replace(message.HotelId,message.Id,version,message); + } + public async Task ReturnToStaff(Conversation message,long version) + { + if(message.Version!=version||message.Delivery?.Automatic!=true||message.Delivery.State!="Rejected")return false; + message.RejectedAutomaticReply=message.Delivery;message.Delivery=null;message.Status="DraftReady";message.AutoReplyDetail="Automatic reply stopped before submission and returned to staff review.";message.Version++; + return await store.Replace(message.HotelId,message.Id,version,message); + } + async Task Claim(Conversation message) + { + if(message.ReplyAddress.Length==0||message.ProviderThreadId.Length==0)return false; + var day=message.Delivery!.AutoDay; + for(int slot=0;slot<20;slot++)if(await store.TryAutoReplyClaim(new AutoReplyClaim{HotelId=message.HotelId,ThreadKey=message.MailboxId+":"+message.ProviderThreadId,RecipientDay=day+":"+message.ReplyAddress,DaySlot=day+":"+slot,ConversationId=message.Id}))return true; + return false; + } + public static async Task CanDeliver(IStore store,IConfiguration? config,Conversation message) + { + var d=message.Delivery;if(d?.Automatic!=true)return true; + if(config?.GetValue("AutoReply:EnableLive")!=true)return false; + var hotel=await store.Get(message.HotelId,message.HotelId);var rule=await store.Get(message.HotelId,d.AutoRuleId);var answer=await store.Get(message.HotelId,d.AutoKnowledgeId); + return d.AutoDay==DateTime.UtcNow.ToString("yyyy-MM-dd")&&hotel?.AutoReplyMode=="Live"&&hotel.AutoReplyEpoch==d.AutoEpoch&&hotel.StaffSendingEnabled&&rule?.Enabled==true&&rule.Version==d.AutoRuleVersion&&FaqMatcher.Normalize(rule.Question)==FaqMatcher.Normalize(message.Body)&&FaqMatcher.SubjectAllowed(message.Subject)&&!FaqMatcher.Sensitive(message.Subject+" "+message.Body)&&rule.KnowledgeId==d.AutoKnowledgeId&&rule.KnowledgeVersion==d.AutoKnowledgeVersion&&answer?.Approved==true&&answer.Version==d.AutoKnowledgeVersion&&d.Body==answer.Answer+"\n\n"+hotel.Signature&&message.AutoReplyHeadersEligible&&message.ReceivedAt>=hotel.AutoReplySince&&message.ReceivedAt>=DateTime.UtcNow.AddHours(-24); + } +} + + + diff --git a/src/GuestOps.Api/AutoReplyEndpoints.cs b/src/GuestOps.Api/AutoReplyEndpoints.cs new file mode 100644 index 0000000..5285597 --- /dev/null +++ b/src/GuestOps.Api/AutoReplyEndpoints.cs @@ -0,0 +1,36 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text; +using MongoDB.Driver; +namespace GuestOps.Web; +public static class AutoReplyEndpoints +{ + public static void Map(RouteGroupBuilder api,bool preview) + { + var group=api.MapGroup("/auto-replies").RequireRateLimiting("pms"); + group.MapGet("/status",(AutoReplyWork work)=>Results.Ok(new{liveConfigured=!preview&&work.LiveConfigured,preview,questions=FaqMatcher.Questions,dailyLimit=20})); + group.MapGet("/rules",async(HttpContext c,IStore store)=>Results.Ok(await store.List(Session.Hotel(c)))); + group.MapGet("/history",async(HttpContext c,IStore store)=>Results.Ok((await store.List(Session.Hotel(c))).Where(m=>m.AutoReplyCheckedAt!=null).OrderByDescending(m=>m.AutoReplyCheckedAt).Select(m=>new{m.Id,m.Subject,m.From,m.AutoReplyCheckedAt,m.AutoReplyMatched,m.AutoReplyDetail,delivery=m.Delivery?.State}))); + group.MapPost("/test",async(AutoTestInput input,HttpContext c,AutoReplyWork work)=>{ + if(!Input.Text(input.Subject,0,200)||!Input.Text(input.Body,1,2000))return Results.BadRequest();return Results.Ok(await work.Test(Session.Hotel(c),input.Subject,input.Body)); + }).RequireAuthorization("Owner"); + group.MapPut("/rules/{question:int}",async(int question,AutoRuleInput input,HttpContext c,IStore store)=>{ + if(question<0||question>=FaqMatcher.Questions.Length||input.Question!=FaqMatcher.Questions[question])return Results.BadRequest(); + var hotel=Session.Hotel(c);var key=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(hotel+":"+question))).ToLowerInvariant()[..32]; + var answer=await store.Get(hotel,input.KnowledgeId);if(answer?.Approved!=true)return Results.BadRequest(new{error="Choose an approved answer from this hotel."}); + var rule=await store.Get(hotel,key);bool exists=rule!=null; + if(!exists&&input.Version!=0)return Input.Conflict(); + rule??=new AutoReplyRule{Id=key,HotelId=hotel};rule.Question=input.Question;rule.KnowledgeId=answer.Id;rule.KnowledgeVersion=answer.Version;rule.Enabled=input.Enabled;rule.ApprovedBy=c.User.FindFirstValue(ClaimTypes.NameIdentifier)!;rule.Version=input.Version+1; + if(exists){if(!await store.Replace(hotel,key,input.Version,rule))return Input.Conflict();} + else{try{await store.Insert(rule);}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return Input.Conflict();}} + await Session.Audit(store,c,"Reviewed FAQ automatic reply rule: "+rule.Question);return Results.Ok(rule); + }).RequireAuthorization("Owner"); + group.MapPut("/mode",async(AutoModeInput input,HttpContext c,IStore store,AutoReplyWork work,GoogleMailbox google)=>{ + if(input.Mode is not ("Off" or "Test" or "Live"))return Results.BadRequest(); + var hotel=await store.Get(Session.Hotel(c),Session.Hotel(c));if(hotel==null)return Results.NotFound(); + if(input.Mode=="Live"&&(preview||!work.LiveConfigured||!google.SendingConfigured||!hotel.StaffSendingEnabled||!input.AcceptanceConfirmed))return Results.BadRequest(new{error="Live mode requires administrator enablement, Google sending, hotel sending and confirmed test-mode acceptance."}); + hotel.AutoReplyMode=input.Mode;hotel.AutoReplyEpoch=Guid.NewGuid().ToString("N");hotel.AutoReplySince=DateTime.UtcNow;hotel.Version=input.Version+1; + if(!await store.Replace(hotel.HotelId,hotel.Id,input.Version,hotel))return Input.Conflict();await Session.Audit(store,c,"Set FAQ automation mode: "+input.Mode);return Results.Ok(hotel); + }).RequireAuthorization("Owner"); + } +} diff --git a/src/GuestOps.Api/GoogleMailbox.cs b/src/GuestOps.Api/GoogleMailbox.cs index 491eaba..e1121a4 100644 --- a/src/GuestOps.Api/GoogleMailbox.cs +++ b/src/GuestOps.Api/GoogleMailbox.cs @@ -64,6 +64,7 @@ public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore var row = new Conversation { HotelId = mailbox.HotelId, MailboxId = mailbox.Id, ProviderMessageId = id, ProviderThreadId = message.GetProperty("threadId").GetString()!, From = Header("From"), Subject = Header("Subject"), Body = body.Length > 0 ? body[..Math.Min(body.Length, 30000)] : "This message has no plain-text body. Open it in Gmail to read it.", ReceivedAt = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(message.GetProperty("internalDate").GetString()!)).UtcDateTime }; row.ReplyAddress = ReplyMime.Address(Header("Reply-To").Length > 0 ? Header("Reply-To") : Header("From")); row.RfcMessageId = Header("Message-ID"); + row.AutoReplyHeadersEligible = FaqMatcher.HeadersEligible(payload,mailbox.Email); await store.Import(row); // deduplicated before advancing the page checkpoint } mailbox.PageToken = page.TryGetProperty("nextPageToken", out var next) ? next.GetString()! : ""; @@ -85,6 +86,12 @@ public sealed class GoogleMailbox(HttpClient http, IConfiguration config, IStore var token = await Token(new() { ["refresh_token"] = protector.Unprotect(mailbox.ProtectedRefreshToken), ["grant_type"] = "refresh_token" }, ct); return token.GetProperty("access_token").GetString()!; } + public async Task AutoReplyThreadUnchanged(Conversation message,string token,CancellationToken ct) + { + var thread=await Read("threads/"+Uri.EscapeDataString(message.ProviderThreadId)+"?format=metadata",token,ct); + if(!thread.TryGetProperty("messages",out var messages)||messages.GetArrayLength()!=1)return false; + var only=messages[0];return only.GetProperty("id").GetString()==message.ProviderMessageId&&only.TryGetProperty("labelIds",out var labels)&&labels.EnumerateArray().Any(x=>x.GetString()=="INBOX")&&!labels.EnumerateArray().Any(x=>x.GetString()=="SENT"); + } public async Task Send(Conversation message, string token, string raw, CancellationToken ct) { using var request = new HttpRequestMessage(HttpMethod.Post, "https://gmail.googleapis.com/gmail/v1/users/me/messages/send") { Content = JsonContent.Create(new { raw, threadId = message.ProviderThreadId }) }; diff --git a/src/GuestOps.Api/Models.cs b/src/GuestOps.Api/Models.cs index 41c8efb..2f78b58 100644 --- a/src/GuestOps.Api/Models.cs +++ b/src/GuestOps.Api/Models.cs @@ -9,6 +9,9 @@ public abstract class TenantDocument : ITenantDocument } public class Hotel : TenantDocument { + public string AutoReplyMode {get;set;}="Off"; + public string AutoReplyEpoch {get;set;}=""; + public DateTime AutoReplySince {get;set;}=DateTime.UtcNow; public bool PaymentsEnabled { get; set; } public bool PmsUpdatesEnabled { get; set; } public bool AiDraftsEnabled { get; set; } @@ -38,10 +41,15 @@ public class KnowledgeEntry : TenantDocument } public class Conversation : TenantDocument { + public bool AutoReplyHeadersEligible {get;set;} + public DateTime? AutoReplyCheckedAt {get;set;} + public string AutoReplyDetail {get;set;}=""; + public bool AutoReplyMatched {get;set;} public string ReplyAddress { get; set; } = ""; public string RfcMessageId { get; set; } = ""; public string[] DraftSources { get; set; } = []; public string DraftReviewNote { get; set; } = ""; + public Delivery? RejectedAutomaticReply {get;set;} public Delivery? Delivery { get; set; } public string MailboxId { get; set; } = ""; public string ProviderMessageId { get; set; } = ""; @@ -95,6 +103,13 @@ public record SendInput(long Version, string Recipient); public record ReplyControlsInput(long Version, bool AiDraftsEnabled, bool StaffSendingEnabled); public class Delivery { + public bool Automatic {get;set;} + public string AutoRuleId {get;set;}=""; + public long AutoRuleVersion {get;set;} + public string AutoKnowledgeId {get;set;}=""; + public long AutoKnowledgeVersion {get;set;} + public string AutoDay {get;set;}=""; + public string AutoEpoch {get;set;}=""; public string Id { get; set; } = Guid.NewGuid().ToString("N"); public string State { get; set; } = "Pending"; public string Recipient { get; set; } = ""; diff --git a/src/GuestOps.Api/Program.cs b/src/GuestOps.Api/Program.cs index 1f29603..b6a7471 100644 --- a/src/GuestOps.Api/Program.cs +++ b/src/GuestOps.Api/Program.cs @@ -34,6 +34,7 @@ builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds builder.Services.AddTransient(); builder.Services.AddHttpClient(c=>c.Timeout=TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(()=>new HttpClientHandler{AllowAutoRedirect=false}); builder.Services.AddTransient(); +builder.Services.AddTransient(); builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(o => { o.Cookie.Name = "guestops.session"; o.Cookie.HttpOnly = true; o.Cookie.SameSite = SameSiteMode.Lax; @@ -130,6 +131,7 @@ if (preview) app.MapPost("/api/preview/start", async (HttpContext c, Cancellatio var api = app.MapGroup("/api").RequireAuthorization(); PmsEndpoints.Map(api,preview); PaymentEndpoints.Map(api,preview); +AutoReplyEndpoints.Map(api,preview); api.MapGet("/hotel", async (HttpContext c, CancellationToken _) => Results.Ok(await store.Get(Session.Hotel(c), Session.Hotel(c)))); api.MapPut("/hotel", async (SettingsInput input, HttpContext c) => { @@ -219,6 +221,12 @@ api.MapPost("/conversations/{id}/send", async (string id, SendInput input, HttpC if (!await store.Replace(item.HotelId, item.Id, input.Version, item)) return Input.Conflict(); await Session.Audit(store, c, "Approved a saved reply for Gmail delivery"); return Results.Ok(item); }); +api.MapPost("/conversations/{id}/delivery/release",async(string id,VersionInput input,HttpContext c,AutoReplyWork work)=> +{ + var item=await store.Get(Session.Hotel(c),id);if(item==null)return Results.NotFound(); + if(preview||!await work.ReturnToStaff(item,input.Version))return Input.Conflict(); + await Session.Audit(store,c,"Returned an unsubmitted automatic reply to staff review");return Results.Ok(item); +}); api.MapPost("/conversations/{id}/delivery/retry", async (string id, VersionInput input, HttpContext c) => { var item = await store.Get(Session.Hotel(c), id); if (item == null) return Results.NotFound(); diff --git a/src/GuestOps.Api/ReplyDelivery.cs b/src/GuestOps.Api/ReplyDelivery.cs index 2736b51..bf98853 100644 --- a/src/GuestOps.Api/ReplyDelivery.cs +++ b/src/GuestOps.Api/ReplyDelivery.cs @@ -18,12 +18,13 @@ public static class ReplyMime var encodedSubject = string.Join("\r\n ", chunks.Select(x => "=?UTF-8?B?" + Convert.ToBase64String(Encoding.UTF8.GetBytes(x)) + "?=")); var body = Convert.ToBase64String(Encoding.UTF8.GetBytes(d.Body), Base64FormattingOptions.InsertLineBreaks); var date = d.UpdatedAt.ToUniversalTime().ToString("ddd, dd MMM yyyy HH:mm:ss +0000", System.Globalization.CultureInfo.InvariantCulture); - var raw = $"From: {mailbox.Email}\r\nTo: {d.Recipient}\r\nDate: {date}\r\nSubject: {encodedSubject}\r\nMessage-ID: {d.MessageId}\r\nIn-Reply-To: {message.RfcMessageId}\r\nReferences: {message.RfcMessageId}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{body}\r\n"; + var automaticHeaders=d.Automatic?"Auto-Submitted: auto-replied\r\nX-Auto-Response-Suppress: All\r\n":""; + var raw = $"{automaticHeaders}From: {mailbox.Email}\r\nTo: {d.Recipient}\r\nDate: {date}\r\nSubject: {encodedSubject}\r\nMessage-ID: {d.MessageId}\r\nIn-Reply-To: {message.RfcMessageId}\r\nReferences: {message.RfcMessageId}\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: base64\r\n\r\n{body}\r\n"; return Convert.ToBase64String(Encoding.UTF8.GetBytes(raw)).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } } -public sealed class ReplyDelivery(IStore store, GoogleMailbox google) +public sealed class ReplyDelivery(IStore store, GoogleMailbox google, IConfiguration? config = null) { public async Task Process(Conversation message, CancellationToken ct) { @@ -48,8 +49,10 @@ public sealed class ReplyDelivery(IStore store, GoogleMailbox google) var hotel = await store.Get(message.HotelId, message.HotelId); var mailbox = await store.Get(message.HotelId, message.MailboxId); if (hotel?.StaffSendingEnabled != true || mailbox?.CanSend != true || mailbox.Status != "Connected" || !google.SendingConfigured) throw new InvalidOperationException("Sending disabled."); + if(!await AutoReplyWork.CanDeliver(store,config,message))throw new InvalidOperationException("Automatic approval no longer valid."); raw = ReplyMime.Build(message, mailbox); token = await google.AccessToken(mailbox, ct); + if(message.Delivery.Automatic&&(!await google.AutoReplyThreadUnchanged(message,token,ct)||!await AutoReplyWork.CanDeliver(store,config,message)))throw new InvalidOperationException("Automatic reply no longer eligible."); ct.ThrowIfCancellationRequested(); } catch { await Save("Rejected", "Nothing was sent. Check reply metadata, hotel controls and Google connection, then retry the approved reply."); return; } diff --git a/src/GuestOps.Api/Store.cs b/src/GuestOps.Api/Store.cs index 1bdc76e..81c4a62 100644 --- a/src/GuestOps.Api/Store.cs +++ b/src/GuestOps.Api/Store.cs @@ -23,6 +23,8 @@ public interface IStore Task> Deliveries(); Task TryInsertPmsChange(PmsChange change); Task TryInsertPayment(PaymentRequest payment); + Task TryAutoReplyClaim(AutoReplyClaim claim); + Task> AutoReplyCandidates(string hotel,string mailbox,DateTime since); } public sealed class MongoStore : IStore { @@ -43,6 +45,8 @@ public sealed class MongoStore : IStore } public async Task Initialize() { + foreach(var field in new[]{"ThreadKey","RecipientDay","DaySlot"})await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(field),new(){Unique=true})); + await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.MailboxId).Ascending(x=>x.AutoReplyCheckedAt).Ascending(x=>x.ReceivedAt))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.Reference),new(){Unique=true})); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Descending(x=>x.UpdatedAt))); await Collection().Indexes.CreateOneAsync(new CreateIndexModel(Builders.IndexKeys.Ascending(x=>x.HotelId).Ascending(x=>x.ReservationId),new CreateIndexOptions{Unique=true,PartialFilterExpression=Builders.Filter.In(x=>x.State,new[]{"Review","Applying","NeedsReview"})})); @@ -101,6 +105,11 @@ public sealed class MongoStore : IStore try { await Insert(message); } catch (MongoWriteException ex) when (ex.WriteError.Category == ServerErrorCategory.DuplicateKey) { /* already durable */ } } + public Task> AutoReplyCandidates(string hotel,string mailbox,DateTime since)=>Collection().Find(Scope(hotel)&Builders.Filter.Eq(x=>x.MailboxId,mailbox)&Builders.Filter.Eq(x=>x.AutoReplyCheckedAt,null)&Builders.Filter.Gte(x=>x.ReceivedAt,since)).SortBy(x=>x.ReceivedAt).Limit(100).ToListAsync(); + public async Task TryAutoReplyClaim(AutoReplyClaim claim) + { + try{await Insert(claim);return true;}catch(MongoWriteException ex) when(ex.WriteError.Category==ServerErrorCategory.DuplicateKey){return false;} + } public async Task TryInsertPayment(PaymentRequest payment) { try { await Insert(payment);return true; } @@ -116,6 +125,11 @@ public sealed class MongoStore : IStore // Explicit Development-only preview store. Production never falls back to this. public sealed class PreviewStore : IStore { + public async Task> AutoReplyCandidates(string hotel,string mailbox,DateTime since)=>(await List(hotel)).Where(x=>x.MailboxId==mailbox&&x.AutoReplyCheckedAt==null&&x.ReceivedAt>=since).OrderBy(x=>x.ReceivedAt).Take(100).ToList(); + public Task TryAutoReplyClaim(AutoReplyClaim claim) + { + lock(gate){if(rows.Where(x=>x.Key.StartsWith("AutoReplyClaim:")).Select(x=>Clone(x.Value)).Any(x=>x.HotelId==claim.HotelId&&(x.ThreadKey==claim.ThreadKey||x.RecipientDay==claim.RecipientDay||x.DaySlot==claim.DaySlot)))return Task.FromResult(false);return Task.FromResult(rows.TryAdd(Key(claim.Id),Json(claim)));} + } public Task TryInsertPayment(PaymentRequest payment) { lock(gate) diff --git a/src/GuestOps.Worker/Program.cs b/src/GuestOps.Worker/Program.cs index eda8bea..75b86ed 100644 --- a/src/GuestOps.Worker/Program.cs +++ b/src/GuestOps.Worker/Program.cs @@ -13,6 +13,8 @@ var keyPath = builder.Configuration["Keys:Path"] ?? throw new InvalidOperationEx builder.Services.AddDataProtection().SetApplicationName("GuestOps-Web").PersistKeysToFileSystem(new DirectoryInfo(keyPath)); builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(25)).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddTransient(); +builder.Services.AddTransient(); +builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); await builder.Build().RunAsync(); @@ -69,3 +71,27 @@ sealed class DeliveryWorker(IStore store, IServiceScopeFactory factory, ILogger< } } } + +sealed class AutoReplyWorker(IStore store,IServiceScopeFactory factory,ILogger log):BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await store.Initialize(); + while(!stoppingToken.IsCancellationRequested) + { + try + { + foreach(var box in await store.Mailboxes()) + { + var hotel=await store.Get(box.HotelId,box.HotelId);if(hotel==null||hotel.AutoReplyMode=="Off")continue; + foreach(var message in await store.AutoReplyCandidates(box.HotelId,box.Id,hotel.AutoReplySince)) + { + stoppingToken.ThrowIfCancellationRequested();using var scope=factory.CreateScope();await scope.ServiceProvider.GetRequiredService().Process(message); + } + } + } + catch(Exception ex) when(!stoppingToken.IsCancellationRequested){log.LogWarning("FAQ automation cycle paused ({Type})",ex.GetType().Name);} + await Task.Delay(TimeSpan.FromSeconds(30),stoppingToken); + } + } +} diff --git a/tests/GuestOps.Tests/AutoReplyTests.cs b/tests/GuestOps.Tests/AutoReplyTests.cs new file mode 100644 index 0000000..c5989e3 --- /dev/null +++ b/tests/GuestOps.Tests/AutoReplyTests.cs @@ -0,0 +1,71 @@ +using GuestOps.Web; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Configuration; +using System.Net; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +static class AutoReplyTests +{ + public static async Task Run(Action check,IStore store) + { + var config=new ConfigurationBuilder().AddInMemoryCollection(new Dictionary{{"AutoReply:EnableLive","true"},{"Google:EnableSending","true"},{"Google:ClientId","fixture"},{"Google:ClientSecret","fixture"}}).Build(); + var hotel=new Hotel{AutoReplyMode="Test",AutoReplyEpoch="epoch",AutoReplySince=DateTime.UtcNow.AddMinutes(-1),StaffSendingEnabled=true};hotel.HotelId=hotel.Id;await store.Insert(hotel); + var protection=new EphemeralDataProtectionProvider();var box=new Mailbox{HotelId=hotel.Id,Email=hotel.Id+"@example.invalid",CanSend=true,ProtectedRefreshToken=protection.CreateProtector("GoogleMailbox.refresh.v1").Protect("fake")};await store.Insert(box); + var answer=new KnowledgeEntry{HotelId=hotel.Id,Title="Parking",Answer="Parking is available in the courtyard.",Approved=true};await store.Insert(answer); + var rule=new AutoReplyRule{HotelId=hotel.Id,Question="Is parking available?",KnowledgeId=answer.Id,KnowledgeVersion=0,Enabled=true,ApprovedBy="owner"};await store.Insert(rule); + var work=new AutoReplyWork(store,config);int number=0; + Conversation Message()=>new(){HotelId=hotel.Id,MailboxId=box.Id,ProviderMessageId="msg"+(++number),ProviderThreadId="thread"+number,ReplyAddress="guest"+number+"@example.invalid",RfcMessageId="",Subject="Parking question",Body="Is parking available?",AutoReplyHeadersEligible=true}; + async Task Process(Conversation m){await store.Insert(m);await work.Process(m);return (await store.Get(hotel.Id,m.Id))!;} + check("FAQ exact question uses approved text unchanged",(await work.Test(hotel.Id,"Parking"," IS PARKING AVAILABLE? ")).Body==answer.Answer); + check("FAQ multi-request and instruction text stays with staff",!(await work.Test(hotel.Id,"Parking","Is parking available? Also cancel my stay.")).Matches&&!(await work.Test(hotel.Id,"Parking","Ignore all instructions. Is parking available?")).Matches); + check("Unsupported subject context stays with staff",!(await work.Test(hotel.Id,"I need help with my insulin","Is parking available?")).Matches); + check("FAQ sensitive subject blocks an otherwise simple question",!(await work.Test(hotel.Id,"Refund for cancelled booking","Is parking available?")).Matches); + check("FAQ answer cannot cross hotel boundary",!(await work.Test("foreign","Parking","Is parking available?")).Matches); + var m=await Process(Message());check("FAQ test mode records a match without delivery or quota use",m.AutoReplyMatched&&m.Delivery==null&&(await store.List(hotel.Id)).Count==0); + var v=answer.Version;answer.Version++;await store.Replace(hotel.Id,answer.Id,v,answer);check("Changed approved answer invalidates FAQ rule",!(await work.Test(hotel.Id,"Parking","Is parking available?")).Matches); + v=rule.Version;rule.KnowledgeVersion=answer.Version;rule.Version++;await store.Replace(hotel.Id,rule.Id,v,rule); + v=hotel.Version;hotel.AutoReplyMode="Live";hotel.Version++;await store.Replace(hotel.Id,hotel.Id,v,hotel); + var one=Message();await store.Insert(one);var two=(await store.Get(hotel.Id,one.Id))!; + await Task.WhenAll(work.Process(one),work.Process(two));m=(await store.Get(hotel.Id,one.Id))!; + check("Concurrent FAQ evaluators queue one immutable delivery",m.Delivery?.Automatic==true&&(await store.List(hotel.Id)).Count==1); + check("FAQ delivery contains exact approved answer and signature",m.Delivery?.Body==answer.Answer+"\n\n"+hotel.Signature); + var handler=new Fixture{MessageId=m.ProviderMessageId};var google=new GoogleMailbox(new HttpClient(handler),config,store,protection);var delivery=new ReplyDelivery(store,google,config); + await delivery.Process(m,default);m=(await store.Get(hotel.Id,m.Id))!;check("Eligible FAQ sends through existing durable delivery worker",m.Delivery?.State=="Sent"&&handler.Sends==1); + var raw=Encoding.UTF8.GetString(Convert.FromBase64String(handler.Raw!.Replace('-','+').Replace('_','/').PadRight((handler.Raw.Length+3)/4*4,'='))); + check("Automatic MIME includes loop-suppression headers",raw.Contains("Auto-Submitted: auto-replied\r\n")&&raw.Contains("X-Auto-Response-Suppress: All\r\n")); + var priorDay=m.Delivery!.AutoDay;m.Delivery.AutoDay=DateTime.UtcNow.AddDays(-1).ToString("yyyy-MM-dd");check("Queued automatic approval cannot carry into another UTC day",!await AutoReplyWork.CanDeliver(store,config,m));m.Delivery.AutoDay=priorDay; + var duplicate=Message();duplicate.ProviderThreadId=m.ProviderThreadId;duplicate=await Process(duplicate);check("FAQ does not queue twice for one Gmail thread",duplicate.Delivery==null); + duplicate=Message();duplicate.ReplyAddress=m.ReplyAddress;duplicate=await Process(duplicate);check("FAQ sender daily limit spans different threads",duplicate.Delivery==null); + var old=Message();old.ReceivedAt=hotel.AutoReplySince.AddSeconds(-1);old=await Process(old);check("FAQ activation never sends an old inbox message",old.Delivery==null); + var edited=Message();edited.Draft="Staff draft";edited=await Process(edited);check("FAQ leaves existing staff drafts alone",edited.Delivery==null&&edited.Draft=="Staff draft"); + var unsafeMessage=Message();unsafeMessage.AutoReplyHeadersEligible=false;unsafeMessage=await Process(unsafeMessage);check("Imported metadata must explicitly qualify for FAQ sending",unsafeMessage.Delivery==null); + var stop=await Process(Message());v=hotel.Version;hotel.AutoReplyEpoch="new-epoch";hotel.Version++;await store.Replace(hotel.Id,hotel.Id,v,hotel);await delivery.Process(stop,default);check("Mode changes stop already queued FAQ replies",(await store.Get(hotel.Id,stop.Id))!.Delivery?.State=="Rejected"&&handler.Sends==1); + check("Rejected automatic reply returns to staff without losing evidence",await work.ReturnToStaff((await store.Get(hotel.Id,stop.Id))!,stop.Version)&&(await store.Get(hotel.Id,stop.Id))!.RejectedAutomaticReply?.State=="Rejected"); + var stale=await Process(Message());v=answer.Version;answer.Version++;await store.Replace(hotel.Id,answer.Id,v,answer);await delivery.Process(stale,default);check("Knowledge edits stop queued FAQ replies before Gmail submission",(await store.Get(hotel.Id,stale.Id))!.Delivery?.State=="Rejected"&&handler.Sends==1); + v=rule.Version;rule.KnowledgeVersion=answer.Version;rule.Version++;await store.Replace(hotel.Id,rule.Id,v,rule); + var active=await Process(Message());handler.MessageId=active.ProviderMessageId;handler.ExtraMessage=true;await delivery.Process(active,default);check("A changed Gmail thread stops automatic reply delivery",(await store.Get(hotel.Id,active.Id))!.Delivery?.State=="Rejected"&&handler.Sends==1);handler.ExtraMessage=false; + active=await Process(Message());handler.MessageId=active.ProviderMessageId;handler.FailSend=true;await delivery.Process(active,default);active=(await store.Get(hotel.Id,active.Id))!;int sends=handler.Sends;await delivery.Process(active,default);check("Uncertain automatic sends are never replayed",active.Delivery?.State=="NeedsReview"&&handler.Sends==sends);handler.FailSend=false; + check("Uncertain automatic reply cannot be released for another send",!await work.ReturnToStaff(active,active.Version)); + config["AutoReply:EnableLive"]="false";active=await Process(Message());check("Server stop control prevents FAQ queueing",active.Delivery==null);config["AutoReply:EnableLive"]="true"; + for(int i=0;i<22;i++)await Process(Message());check("FAQ quota is enforced by durable unique daily slots",(await store.List(hotel.Id)).Count==20); + check("FAQ candidate query cannot cross hotels",(await store.AutoReplyCandidates("other",box.Id,DateTime.UtcNow.AddDays(-1))).Count==0); + JsonObject Payload()=>new(){["mimeType"]="text/plain",["headers"]=new JsonArray(new JsonObject{["name"]="From",["value"]="guest@example.invalid"},new JsonObject{["name"]="To",["value"]=box.Email})}; + bool Eligible(JsonObject p)=>FaqMatcher.HeadersEligible(JsonSerializer.SerializeToElement(p),box.Email); + check("FAQ import accepts a single plain-text direct message",Eligible(Payload())); + foreach(var pair in new[]{("Cc","other@example.invalid"),("To","other@example.invalid"),("In-Reply-To",""),("Auto-Submitted","auto-replied"),("List-Id","list"),("Reply-To","other@example.invalid"),("Return-Path","<>"),("X-Auto-Response-Suppress","All")}){var p=Payload();p["headers"]!.AsArray().Add(new JsonObject{["name"]=pair.Item1,["value"]=pair.Item2});check("FAQ import rejects "+pair.Item1,!Eligible(p));} + var attachment=Payload();attachment["filename"]="details.pdf";check("FAQ import rejects attachments",!Eligible(attachment));var html=Payload();html["mimeType"]="multipart/alternative";check("FAQ import leaves HTML alternatives for staff",!Eligible(html)); + } + sealed class Fixture:HttpMessageHandler + { + public string MessageId="";public string? Raw;public int Sends;public bool ExtraMessage,FailSend; + static HttpResponseMessage Json(object o)=>new(HttpStatusCode.OK){Content=new StringContent(JsonSerializer.Serialize(o),Encoding.UTF8,"application/json")}; + protected override async Task SendAsync(HttpRequestMessage req,CancellationToken ct) + { + if(req.RequestUri!.AbsoluteUri=="https://oauth2.googleapis.com/token")return Json(new{access_token="fake"}); + if(req.RequestUri.Host=="gmail.googleapis.com"&&req.RequestUri.AbsolutePath.Contains("/threads/"))return Json(new{messages=ExtraMessage?new[]{new{id=MessageId,labelIds=new[]{"INBOX"}},new{id="staff-reply",labelIds=new[]{"SENT"}}}:new[]{new{id=MessageId,labelIds=new[]{"INBOX"}}}}); + if(req.RequestUri.AbsoluteUri=="https://gmail.googleapis.com/gmail/v1/users/me/messages/send"){Sends++;using var j=JsonDocument.Parse(await req.Content!.ReadAsStringAsync(ct));Raw=j.RootElement.GetProperty("raw").GetString();if(FailSend)throw new TaskCanceledException();return Json(new{id="sent"});} + throw new InvalidOperationException("Unexpected fixture request."); + } + } +} diff --git a/tests/GuestOps.Tests/Program.cs b/tests/GuestOps.Tests/Program.cs index fc2f829..ab099c0 100644 --- a/tests/GuestOps.Tests/Program.cs +++ b/tests/GuestOps.Tests/Program.cs @@ -17,7 +17,8 @@ try { await ReplyTests.Run(Check, store); await PmsTests.Run(Check, store); - await PaymentTests.Run(Check, store); + await PaymentTests.Run(Check, store); + await AutoReplyTests.Run(Check, store); var a = new Hotel { Name = "Hotel A" }; a.HotelId = a.Id; var b = new Hotel { Name = "Hotel B" }; b.HotelId = b.Id; await store.Insert(a); await store.Insert(b); @@ -95,6 +96,16 @@ try Check("Foreign payment cannot be approved",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.NotFound); Check("Foreign payment cannot be reconciled",(await two.PostAsJsonAsync($"/api/payments/requests/{paymentId}/check",new{version=0})).StatusCode==HttpStatusCode.NotFound); Check("Preview sample invoice cannot be created",(await one.PostAsJsonAsync($"/api/payments/requests/{paymentId}/create",new{version=0,emailAndAmountApproved=true})).StatusCode==HttpStatusCode.BadRequest); + var autoStatus=await Read(one,"/api/auto-replies/status"); + Check("Preview cannot enable FAQ live sending",!autoStatus.GetProperty("liveConfigured").GetBoolean()&&(await one.PutAsJsonAsync("/api/auto-replies/mode",new{mode="Live",version=0,acceptanceConfirmed=true})).StatusCode==HttpStatusCode.BadRequest); + Check("FAQ test mode can be saved without sending",(await one.PutAsJsonAsync("/api/auto-replies/mode",new{mode="Test",version=0,acceptanceConfirmed=false})).IsSuccessStatusCode); + var ownKnowledge=await Read(one,"/api/knowledge");var answerId=ownKnowledge[0].GetProperty("id").GetString(); + Check("FAQ rule rejects another hotel's knowledge",(await two.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=true,version=0})).StatusCode==HttpStatusCode.BadRequest); + Check("Owner can save a reviewed FAQ rule",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=true,version=0})).IsSuccessStatusCode); + var autoTest=await one.PostAsJsonAsync("/api/auto-replies/test",new{subject="Parking",body="Is parking available?"}); + Check("FAQ content tester returns a match without a delivery",autoTest.IsSuccessStatusCode&&JsonDocument.Parse(await autoTest.Content.ReadAsStringAsync()).RootElement.GetProperty("matches").GetBoolean()); + Check("FAQ rule updates reject stale versions",(await one.PutAsJsonAsync("/api/auto-replies/rules/3",new{question="Is parking available?",knowledgeId=answerId,enabled=false,version=0})).StatusCode==HttpStatusCode.Conflict); + Check("Uncertain or foreign replies cannot be released",(await two.PostAsJsonAsync($"/api/conversations/{id}/delivery/release",new{version=0})).StatusCode==HttpStatusCode.NotFound); var cookie=(await one.GetAsync("/api/session")).Headers; Check("Session response is not cacheable", cookie.CacheControl?.NoStore==true); await one.PostAsJsonAsync("/api/auth/logout",new {}); @@ -109,3 +120,4 @@ finally } + diff --git a/tests/production_smoke.py b/tests/production_smoke.py index ef18ad3..fd3dc11 100644 --- a/tests/production_smoke.py +++ b/tests/production_smoke.py @@ -43,6 +43,9 @@ request("/api/auth/login", "POST", {"email": os.environ["BOOTSTRAP_EMAIL"], "pas session = request("/api/session") assert session["user"]["role"] == "Owner" csrf = session["csrfToken"] +auto_status = request("/api/auto-replies/status") +assert auto_status["liveConfigured"] is False +request("/api/auto-replies/mode", "PUT", {"mode": "Live", "version": 0, "acceptanceConfirmed": True}, expected=400) payment_status = request("/api/payments/status") assert payment_status["configured"] is False and payment_status["createsConfigured"] is False assert "securityKey" not in payment_status diff --git a/web/src/AutomationPage.tsx b/web/src/AutomationPage.tsx new file mode 100644 index 0000000..e5087c5 --- /dev/null +++ b/web/src/AutomationPage.tsx @@ -0,0 +1,23 @@ +import {useEffect,useState} from 'react'; +import {api,type Hotel,type Knowledge} from './api'; +type Rule={id:string;question:string;knowledgeId:string;knowledgeVersion:number;enabled:boolean;version:number}; +type Status={liveConfigured:boolean;preview:boolean;questions:string[];dailyLimit:number}; +type Decision={matches:boolean;reason:string;body:string}; +type History={id:string;subject:string;from:string;autoReplyCheckedAt:string;autoReplyMatched:boolean;autoReplyDetail:string;delivery:string|null}; +type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise)=>Promise;onHotel:(h:Hotel)=>void}; +export function AutomationPage({hotel,owner,busy,run,onHotel}:Props){ + const [status,setStatus]=useState(null),[rules,setRules]=useState([]),[knowledge,setKnowledge]=useState([]),[history,setHistory]=useState([]); + const [question,setQuestion]=useState(0),[answer,setAnswer]=useState(''),[enabled,setEnabled]=useState(false),[subject,setSubject]=useState('Parking question'),[body,setBody]=useState('Is parking available?'),[result,setResult]=useState(null); + async function refresh(){const [s,r,k,h]=await Promise.all([api('/auto-replies/status'),api('/auto-replies/rules'),api('/knowledge'),api('/auto-replies/history')]);setStatus(s);setRules(r);setKnowledge(k);setHistory(h);} + useEffect(()=>{run(refresh);},[hotel.id]); + const current=rules.find(r=>r.question===status?.questions[question]); + useEffect(()=>{setAnswer(current?.knowledgeId||'');setEnabled(current?.enabled||false);},[question,current?.id,current?.version]); + async function mode(value:string){await run(async()=>{if(value==='Live'&&!window.confirm('Enable automatic FAQ sending for new incoming messages? Confirm that you reviewed test-mode results and accepted Gmail delivery with a sandbox mailbox. Up to 20 replies per hotel per UTC day may be sent.'))return;onHotel(await api('/auto-replies/mode','PUT',{mode:value,version:hotel.version,acceptanceConfirmed:value==='Live'}));});} + async function save(e:React.FormEvent){e.preventDefault();await run(async()=>{const item=await api(`/auto-replies/rules/${question}`,'PUT',{question:status!.questions[question],knowledgeId:answer,enabled,version:current?.version||0});setRules(old=>[item,...old.filter(r=>r.id!==item.id)]);setResult(null);});} + return
Simple questions, thoughtful answers

FAQ automation

Start in test mode. Let approved answers handle a small set of straightforward questions.

+

Automation mode: {hotel.autoReplyMode||'Off'}

Only exact, complete FAQ questions qualify. Extra requests, attachments and conversations already in progress stay with your team.

Test mode sends nothing. Live mode requires administrator enablement and Gmail sending. Each mode change starts with new incoming messages; existing inbox messages are not sent automatically.

Maximum 20 automatic replies per hotel per UTC day, one per sender per UTC day, and one per Gmail thread. Turning off stops queued replies when the worker next checks them; a request already submitted to Gmail cannot be recalled.

{status?.preview&&

Sample workspace: the question tester works here. Live sending is disabled.

}
+

Review a FAQ rule

{answer&&

{knowledge.find(k=>k.id===answer)?.answer}

}

The approved answer is sent exactly as saved, with the hotel signature. Editing the answer pauses matching until this rule is reviewed and saved again.

+

Try a question

{e.preventDefault();run(async()=>setResult(await api('/auto-replies/test','POST',{subject,body})));}}>