Files
Assistant/cmd/commit-runtime/internal_notification.go
T
m 5d4e064a94
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 15s
DEV-000065 assistant presence attention surface
2026-08-17 16:23:16 +03:00

136 lines
5.8 KiB
Go

package main
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"git.cin.su/m/Assistant/adapters/notificationexecutor"
commitcontract "git.cin.su/m/Assistant/contracts/commit"
"git.cin.su/m/Assistant/contracts/continuity"
"git.cin.su/m/Assistant/contracts/identity"
"git.cin.su/m/Assistant/contracts/work"
commitdomain "git.cin.su/m/Assistant/modules/commit/domain"
"git.cin.su/m/Assistant/modules/commit/ports"
"git.cin.su/m/Assistant/platform/ids"
)
const prospectiveNotificationActor identity.ActorID = "domovoy-creator-actor"
type internalNotificationContinuity interface {
InternalItem(context.Context, continuity.ProspectiveItemID) (continuity.ProspectiveItem, error)
}
type internalNotificationHandler struct {
core creatorSessionSource
continuity internalNotificationContinuity
authority creatorAuthoritySource
repository creatorCommitRepository
executor ports.Executor
newID func() (ids.ID, error)
now func() time.Time
}
type internalProspectiveNotificationRequest struct {
ProspectiveItemID continuity.ProspectiveItemID `json:"prospective_item_id"`
ExpectedRevision continuity.ProspectiveRevision `json:"expected_revision"`
}
func registerInternalNotificationRoutes(mux *http.ServeMux, handler internalNotificationHandler, token string) {
mux.HandleFunc("POST /v1/internal/continuity/notify-due", requireCommitToken(token, handler.notifyDue))
}
func (h internalNotificationHandler) notifyDue(w http.ResponseWriter, r *http.Request) {
var request internalProspectiveNotificationRequest
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8<<10))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&request); err != nil || ensureCreatorEOF(decoder) != nil || request.ProspectiveItemID == "" || request.ExpectedRevision == 0 {
http.Error(w, "invalid prospective notification request", http.StatusBadRequest)
return
}
item, err := h.continuity.InternalItem(r.Context(), request.ProspectiveItemID)
if errors.Is(err, errCreatorSubjectMissing) {
http.Error(w, "prospective item not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "Continuity item unavailable", http.StatusServiceUnavailable)
return
}
if item.State != continuity.ProspectiveDelivered || item.Revision != request.ExpectedRevision || item.OwnerPrincipalID == "" || item.ScopeID == "" {
http.Error(w, "prospective item not deliverable", http.StatusConflict)
return
}
grant, scope, err := h.authority.LoadGrant(r.Context(), creatorGrantID)
if err != nil || scope.ID != item.ScopeID {
http.Error(w, "Creator authority unavailable", http.StatusConflict)
return
}
identityBinding, err := h.core.Binding(r.Context(), prospectiveNotificationActor, item.OwnerPrincipalID)
if err != nil {
http.Error(w, "Creator identity binding unavailable", http.StatusConflict)
return
}
capabilityBinding, err := h.core.ActiveCapabilityBinding(r.Context(), commitcontract.CapabilityProspectiveNotification, item.OwnerPrincipalID)
if err != nil {
http.Error(w, "notification capability unavailable", http.StatusConflict)
return
}
intentID, authorizedID, attemptID, err := internalActionIDs(h.newID)
if err != nil {
http.Error(w, "action identity unavailable", http.StatusServiceUnavailable)
return
}
title := "Домовой: напоминание"
message := "Пора: " + strings.TrimSpace(item.Summary)
payload := notificationexecutor.Payload{ActionIntentID: intentID, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, ProspectiveItemID: string(item.ID), ExpectedRevision: int64(item.Revision), Title: title, Message: message}
digest, err := notificationexecutor.Digest(payload)
if err != nil {
http.Error(w, "notification evidence unavailable", http.StatusInternalServerError)
return
}
proposal := commitcontract.ActionProposal{
IntentID: intentID, ActorID: prospectiveNotificationActor, CapabilityID: commitcontract.CapabilityProspectiveNotification,
Target: work.TaskTarget{ScopeID: item.ScopeID, Revision: 1}, Operation: commitcontract.OperationDeliverProspectiveNotification,
NotificationSubject: commitcontract.ProspectiveNotificationSubject{ProspectiveItemID: item.ID, ExpectedRevision: item.Revision, TemplateCode: commitcontract.ProspectiveDeliveredTemplateV1, BindingID: capabilityBinding.ID, BindingRevision: capabilityBinding.Revision, RenderedTitle: title, RenderedMessage: message, RequestDigest: digest},
}
action, err := commitdomain.Authorize(proposal, commitdomain.AuthorizationContext{Binding: identityBinding, Scope: scope, Grant: grant}, authorityValidator{}, authorizedID, h.now().UnixMilli())
if err != nil {
http.Error(w, "Creator authorization denied", http.StatusConflict)
return
}
if err = h.repository.SaveAuthorized(r.Context(), action); err != nil {
http.Error(w, "commit authorized action", http.StatusInternalServerError)
return
}
attempt, operationErr := executeAuthorized(r.Context(), h.repository, h.executor, action, attemptID)
if operationErr != nil && !errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
http.Error(w, "execute committed notification", http.StatusInternalServerError)
return
}
status := http.StatusCreated
if errors.Is(operationErr, commitdomain.ErrUnknownOutcome) {
status = http.StatusAccepted
}
writeCreatorActionResponse(w, status, action, attempt)
}
func internalActionIDs(newID func() (ids.ID, error)) (commitcontract.ActionIntentID, commitcontract.AuthorizedActionID, commitcontract.ActionAttemptID, error) {
intent, err := newID()
if err != nil {
return "", "", "", err
}
authorized, err := newID()
if err != nil {
return "", "", "", err
}
attempt, err := newID()
if err != nil {
return "", "", "", err
}
return commitcontract.ActionIntentID(intent), commitcontract.AuthorizedActionID(authorized), commitcontract.ActionAttemptID(attempt), nil
}