Skip to content

notificationExt 扩展参考 ​

原文: https://finproducts.atlassian.net/wiki/spaces/JIDD3/pages/34793685250/FIN+5.3.1+notificationExt 中文整理基于 FIN 5.3.1

导读: 抽象常量类 AbstractMailerTarget : NotificationTarget

以下为 API 参考,保留英文以便对照源码;目录已列出(英文标题便于检索);概述已尽量中译。


目录(导读) ​

  • classes
  • Slot Details
    • _templateCache
    • _templateMime
    • generateBody
    • getAttachments
    • make
    • sendEmail
    • subject
    • templateCache
    • templateRef
    • body
    • mimeType
    • TYPE
    • execute
    • to
    • wrap
    • emailPart
    • files
    • toFinal
    • _topicCache
    • topic
    • shouldSilenceEmail
    • silenceEndTime
    • silenceStartTime
    • user
    • notifyTarget
    • TARGET_TAG_NAME
    • notify
    • notifyManual
    • onHandleNotification
    • onStart
    • onStop
    • web
    • addInterests
    • context
    • currentTries
    • incrementTries
    • interest
    • target
    • _sockets
    • lib
    • onGet
    • sendNotification
    • disabled
    • include
    • log
    • notificationOn
    • notificationTarget
    • onFilter
    • addDigest
    • addImmediate
    • digestUsers
    • immediateUsers
    • on
    • topicOn
    • CACHE_PERIOD
    • getDigestUsersForInterest
    • getImmediateUsersForInterest
    • isExpired
    • refreshIfExpired
    • topicName

概述(中译) ​

src 抽象常量类 AbstractMailerTarget : NotificationTarget

构造函数

字段

方法

插槽详情

src 受保护的 const AtomicRef _templateCache := AtomicRef.<ctor (Unsafe.<ctor ([:]))

src 受保护的常量 AtomicRef _templateMime := AtomicRef.<ctor ()

src 受保护的 ContentBody generateBody(HxContext context, Obj interest)


API 正文(英文) ​

getAttachments ​

src protected File[] getAttachments(HxContext context, Obj interest)


make ​

src new make(Dict rec, |This| f)


sendEmail ​

src protected Void sendEmail(HxContext context, Obj interest, Str[] to)


subject ​

src @TypedTag const Str subject


templateCache ​

src protected Str:TemplateProcessor templateCache()


templateRef ​

src @TypedTag const Ref? templateRef


ContentBody ​

src const class ContentBody : Obj

Constructors

make

Fields

body
mimeType

Slot Details ​


body ​

src const Str body


make ​

src new make(Str body, MimeType mimeType)


mimeType ​

src const MimeType mimeType


DirectEmailNotificationTarget ​

src const class DirectEmailNotificationTarget : AbstractMailerTarget

Constructors

make
wrap

Fields

TYPE
to

Methods

execute

Slot Details ​


TYPE ​

src const static Str TYPE := "directEmail"


execute ​

src virtual override Future execute(HxContext context, Obj interest)


make ​

src new make(Dict rec, |This| f)


to ​

src @TypedTag const Str to


wrap ​

src static new wrap(Dict? dict)


EmailParts ​

src class EmailParts : Obj

Constructors

make

Fields

emailPart
files

Methods

toFinal

Slot Details ​


emailPart ​

src TextPart emailPart


files ​

src FilePart[] files


make ​

src new make(ContentBody email, File[] attachments)


toFinal ​

src EmailPart toFinal()


EmailTopicNotificationTarget ​

src const class EmailTopicNotificationTarget : AbstractMailerTarget

Constructors

make
wrap

Fields

TYPE
_topicCache
topic

Methods

execute

Slot Details ​


TYPE ​

src const static Str TYPE := "emailTopic"


_topicCache ​

src const AtomicRef _topicCache := AtomicRef.<ctor>()


execute ​

src virtual override Future execute(HxContext context, Obj interest)


make ​

src new make(Dict rec, |This| f)


topic ​

src @TypedTag const Str topic


wrap ​

src static new wrap(Dict? dict)


EmailUser ​

src const class EmailUser : Obj

Constructors

make

Fields

silenceEndTime
silenceStartTime
user

Methods

shouldSilenceEmail

Slot Details ​


make ​

src new make(HxUser user)


shouldSilenceEmail ​

src Bool shouldSilenceEmail(Time when)


silenceEndTime ​

src const Time? silenceEndTime


silenceStartTime ​

src const Time? silenceStartTime


user ​

src const HxUser user


NotificationFuncs ​

src class NotificationFuncs : Obj

Methods

notifyTarget

Slot Details ​


notifyTarget ​

src @Axon static Void notifyTarget(Obj target, Obj interest)


NotificationLib ​

src const class NotificationLib : HxLib

Constructors

make

Fields

TARGET_TAG_NAME
web

Methods

notify
notifyManual
onHandleNotification
onStart
onStop

Slot Details ​


TARGET_TAG_NAME ​

src const static Str TARGET_TAG_NAME := "notificationTarget"


make ​

src new make()


notify ​

src Future notify(HxContext context, Str subject, Obj interest)


notifyManual ​

src Future notifyManual(HxContext context, Ref targetRef, Obj interest)


onHandleNotification ​

src protected Obj? onHandleNotification(NotificationQueueItem item)


onStart ​

src virtual override Void onStart()


onStop ​

src virtual override Void onStop()


web ​

src virtual const override NotificationRestAPI web := NotificationRestAPI.<ctor>(this)


NotificationQueueItem ​

src const class NotificationQueueItem : Obj

Constructors

make

Fields

target

Methods

addInterests
context
currentTries
incrementTries
interest

Slot Details ​


addInterests ​

src Void addInterests(Obj[] interests)


context ​

src HxContext context()


currentTries ​

src Int currentTries()


incrementTries ​

src Void incrementTries()


interest ​

src Obj interest()


make ​

src new make(HxContext context, Obj[] interest, NotificationTarget target)


target ​

src const NotificationTarget target


NotificationRestAPI ​

src const class NotificationRestAPI : HxLibWeb

Constructors

make

Fields

_sockets

Methods

lib
onGet
sendNotification

Slot Details ​


_sockets ​

src const Unsafe _sockets := Unsafe.<ctor>([,])


lib ​

src virtual override NotificationLib lib()


make ​

src new make(NotificationLib lib)


onGet ​

src virtual override Void onGet()


sendNotification ​

src Void sendNotification(Str subject, Obj notification)


NotificationTarget ​

src abstract const class NotificationTarget : TypedDict

Constructors

make

Fields

disabled
log
notificationOn
notificationTarget
onFilter

Methods

execute
include

Slot Details ​


disabled ​

src @TypedTag const Bool disabled


execute ​

src abstract Future execute(HxContext context, Obj interest)


include ​

src Bool include(HxContext context, Obj interest)


log ​

src protected const Log log := ...


make ​

src new make(Dict rec, |This| f)


notificationOn ​

src @TypedTag const Str? notificationOn


notificationTarget ​

src @TypedTag const Str notificationTarget


onFilter ​

src const Filter? onFilter


PushBulletNotificationTarget ​

src const class PushBulletNotificationTarget : NotificationTarget

Constructors

make
wrap

Fields

TYPE

Methods

execute

Slot Details ​


TYPE ​

src const static Str TYPE := "pushBullet"


execute ​

src virtual override Future execute(HxContext context, Obj interest)


make ​

src new make(Dict rec, |This| f)


wrap ​

src static new wrap(Dict? dict)


Topic ​

src const class Topic : TypedDict

Constructors

make
wrap

Fields

on
topicOn

Methods

addDigest
addImmediate
digestUsers
immediateUsers

Slot Details ​


addDigest ​

src Void addDigest(EmailUser user)


addImmediate ​

src Void addImmediate(EmailUser user)


digestUsers ​

src EmailUser[]? digestUsers()


immediateUsers ​

src EmailUser[]? immediateUsers()


make ​

src new make(Dict rec, |This| f)


on ​

src const Filter on


topicOn ​

src @TypedTag const Str topicOn


wrap ​

src static new wrap(Dict dict)


TopicCache ​

src class TopicCache : Obj

Constructors

make

Fields

CACHE_PERIOD
topicName

Methods

getDigestUsersForInterest
getImmediateUsersForInterest
isExpired
refreshIfExpired

Slot Details ​


CACHE_PERIOD ​

src const static Duration CACHE_PERIOD := 27sec


getDigestUsersForInterest ​

src HxUser[] getDigestUsersForInterest(HxContext context, Obj interest)


getImmediateUsersForInterest ​

src HxUser[] getImmediateUsersForInterest(HxContext context, Obj interest)


isExpired ​

src Bool isExpired()


make ​

src new make(HxContext context, Str topicName)


refreshIfExpired ​

src Void refreshIfExpired()


topicName ​

src const Str topicName


using haystack
using concurrent
using hx
using hxIO
using skyarcd
using email
using inet
using mustacheExt
using finSmtp

const abstract class AbstractMailerTarget : NotificationTarget 
{
  @TypedTag
  const Str subject
  
  @TypedTag
  const Ref? templateRef
  
  protected const AtomicRef _templateCache := AtomicRef(Unsafe([:]))
  protected const AtomicRef _templateMime := AtomicRef()
  
  new make(Dict rec, |This| f): super(rec, f) {}
  
  protected ContentBody generateBody(HxContext context, Obj interest)
  {
    // Process template
    itemType := interest.typeof.name
    
    if (interest is List && ((List) interest).size == 1)
      interest = ((List) interest).get(0)
            
    if (!this.templateCache.containsKey(itemType))
    {
      // Get user defined template
      Str? template
      Str:Str partials := [:]
      
      IOHandle? templateHandle
      
      if (this.templateRef != null)
      {
        templateRec := context.db.readById(this.templateRef)
        templateHandle = IOHandle.fromObj(context.rt, templateRec)
        
        binTag := templateRec.get("file") as Bin
        this._templateMime.getAndSet(binTag.mime)
      }
      else // Get Built in Templates
      if (interest is Grid || interest is List)
      {
        if (interest is Grid && ((Grid) interest).meta.has("digest"))
        {
          listPartial := IOHandle.fromObj(context.rt, `fan://notificationExt/resources/alarming/DefaultEmailListPartial.html`)
          templateHandle = IOHandle.fromObj(context.rt, `fan://notificationExt/resources/alarming/DefaultEmailDigestTemplate.html`)
          
          partials.set("DefaultEmailListPartial", listPartial.inToBuf.readAllStr)
        }
        else
          templateHandle = IOHandle.fromObj(context.rt, `fan://notificationExt/resources/alarming/DefaultEmailListTemplate.html`)
        
        this._templateMime.getAndSet(MimeType.fromStr("text/html"))
      }
      else
      {
        alarmPartial := IOHandle.fromObj(context.rt, `fan://notificationExt/resources/alarming/DefaultAlarmPartial.html`)
        templateHandle = IOHandle.fromObj(context.rt, `fan://notificationExt/resources/alarming/DefaultEmailTemplate.html`)
        
        partials.set("DefaultAlarmPartial", alarmPartial.inToBuf.readAllStr)
        
        this._templateMime.getAndSet(MimeType.fromStr("text/html"))
      }
      
      template = templateHandle.inToBuf.readAllStr
            
      processor := TemplateProcessor(context)
      processor.parseTemplate(template, partials)
      
      templateCache.set(itemType, processor)
    }
    
    // Remove "info" key from metadata, we don’t need the data for every variable in the program.”
    if (interest is Dict && ((Dict)interest).has("metadata"))
    { 
      metadata := ((Dict)interest).get("metadata")
      if (metadata is Dict)
      {
        filtered := Etc.dictRemove(metadata, "info")
        interest = Etc.dictSet(interest, "metadata", filtered)
      }
    }
  
    return ContentBody(
      this.templateCache.get(itemType).applyTemplate(interest),
      this._templateMime.val
    )
  }
  
  protected File[] getAttachments(HxContext context, Obj interest)
  {
    File[] files := [,]
    
    if (this.templateRef != null)
    {
      // Template
      templateRec := context.db.readById(this.templateRef)
      ((Context) context).asCur | -> Void |
      {
        templateRec.each | value, tagName | {
          if (tagName == "file")
            return
          
          if (value is Bin)
          {
            handle := (IOHandle) IOFuncs.ioBin(templateRec, tagName)
            
            files.add(
              handle.inToBuf.toFile(
                "rec://${templateRef.id}/${tagName}".toUri
              )
            )
          }
        }
      }
    }
    else
    {
      if (interest is Grid)
      {
        if (((Grid) interest).meta.has("digest"))
        {
          interest = ((Grid) interest).toRows.flatMap |row| { row.get("alarms")  }
        }
        else
          interest = ((Grid) interest).toRows
      }
      
      if (interest is Dict)
        interest = [interest]
      
      Dict[] items := interest
      
      Str:Str? stateNames := [:]
      Str:Str? kindNames := [:]
      
      items.each | item | {
        
        // Set State
        state := ((Str?) item.get("state")) ?: "unknown"
  
        if (item.has("acked"))
          state = "acknowledged${state}"
        
        stateNames.set(state, null)
        
        // Set Kinds (From Alarm Metadata [tags])
        if (item.has("metadata"))
        {
          Obj metadata := item.get("metadata")
          
          if (metadata is Dict)
          {
            ((Dict) metadata).each | value | {
              kind := Kind.fromVal(value)
              kindNames.set(kind.name, null)
            }
          }
        }
      }
      
      // Get State Icons
      stateNames.keys.each | state | {
        try
        {
          stateIcon := IOHandle.fromObj(
            context.rt,
            "fan://notificationExt/resources/alarming/${state}Icon.png".toUri
          )
          
          files.add(
            stateIcon.inToBuf.toFile(
              "fan://notificationExt/resources/alarming/${state}Icon.png".toUri
            )
          )
        }
        catch (Err error)
        {
          // If failed to find state icon, default to off normal alarm icon
          
          stateIcon := IOHandle.fromObj(
            context.rt,
            "fan://notificationExt/resources/alarming/offNormalIcon.png".toUri
          )
          
          files.add(
            stateIcon.inToBuf.toFile(
              "fan://notificationExt/resources/alarming/${state}Icon.png".toUri
            )
          )
        }
      }

      // Get Kind Icons
      kindNames.keys.each | kindName | {
        try
        {
          typeHandle := IOHandle.fromObj(
            context.rt,
            "fan://notificationExt/resources/alarming/${kindName}Icon.png".toUri
          )
          
          files.add(
            typeHandle.inToBuf.toFile(
              "fan://notificationExt/resources/alarming/${kindName}Icon.png".toUri
            )
          )
        }
        catch (Err error)
        {
          // If failed to find kind icon, default to tagIcon
          
          typeHandle := IOHandle.fromObj(
            context.rt,
            "fan://notificationExt/resources/alarming/tagIcon.png".toUri
          )
          
          files.add(
            typeHandle.inToBuf.toFile(
              "fan://notificationExt/resources/alarming/${kindName}Icon.png".toUri
            )
          )
        }
      }
    }
    
    return files
  }
  
  protected Void sendEmail(
    HxContext context,
    Obj interest,
    Str[] to)
  {
    // Create email parts
    parts := EmailParts(
      this.generateBody(context, interest),
      this.getAttachments(context, interest)
    )
    
    EmailMod mailLib := context.rt.lib("email")
    FinSmtpMod finSmtp := context.rt.lib("finSmtp")
    
    proposedSubject := (interest is Dict)
      ? Etc.macro(this.subject, interest)
      : ((interest is Grid)
        ? Etc.macro(this.subject, ((Grid) interest).meta)
        : this.subject)
    
    email := email::Email() {
      it.subject = proposedSubject
      it.from = mailLib.settings.from
      it.bcc = to
      it.body = parts.toFinal
    }
    
    token  := finSmtp.getAuthToken()
    client := finSmtp::SmtpClient()

    client.host = mailLib.settings.uri.host
    client.port = mailLib.settings.uri.port ?: 25
    client.username = mailLib.settings.username
    client.password = token ?: mailLib.sys.host.db.passwords.get("email")
    client.oauth = token != null
    client.ssl = mailLib.settings.tls
    client.log = this.log
    client.socketConfig.setTimeouts(SocketConfig.cur.connectTimeout)
        
    client.send(email)
  }
  
  protected Str:TemplateProcessor templateCache()
  {
    return ((Unsafe) this._templateCache.val).val
  }
}

class EmailParts
{
  TextPart emailPart
  FilePart[] files
  
  new make(ContentBody email, File[] attachments)
  {
    this.emailPart = TextPart()
    this.emailPart.headers.set("Content-Type", email.mimeType.toStr)
    this.emailPart.text = email.body
    
    this.files = attachments.map | file | {
      part := FilePart()
      part.file = file
      part.headers.set("Content-Type", "${file.mimeType}; name=${file.basename}")
      part.headers.set("Content-Id", "<${file.basename}>")
      part.headers.set("Content-Disposition", "inline; filename=${file.basename}")
      part.headers.set("Content-Location", file.basename)
      
      return part
    }
  }
  
  public EmailPart toFinal()
  {
    if (this.files.size == 0)
      return this.emailPart
    
    EmailPart[] allParts := [,]
    allParts.add(this.emailPart)
    allParts.addAll(this.files)
    
    multiPart := MultiPart()
    multiPart.parts = allParts
    
    return multiPart
  }
}

const class ContentBody
{
  const Str body
  const MimeType mimeType
  
  new make(Str body, MimeType mimeType)
  {
    this.body = body
    this.mimeType = mimeType
  }
  
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using hx
using haystack
using concurrent

const class DirectEmailNotificationTarget : AbstractMailerTarget
{
  public static const Str TYPE := "directEmail"
  
  @TypedTag
  const Str to
      
  new make(Dict rec, |This| f): super(rec, f) {}
      
  public override Future execute(HxContext context, Obj interest)
  {
    this.sendEmail(
      context,
      interest,
      this.to.split(',', true)
    )
    
    return Future.makeCompletable.complete(null)
  }
  
  static new wrap(Dict? dict)
  {
    if (dict == null)
      return null
    
    return create(DirectEmailNotificationTarget#, dict)
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using hx
using haystack
using concurrent

const class EmailTopicNotificationTarget : AbstractMailerTarget
{
  public static const Str TYPE := "emailTopic"
  
  @TypedTag
  const Str topic

  const AtomicRef _topicCache := AtomicRef()
  
  new make(Dict rec, |This| f): super(rec, f) {}
  
  public override Future execute(HxContext context, Obj interest)
  {
    if (this.cache == null)
    {
      // Create Cache
      this._topicCache.getAndSet(Unsafe(TopicCache(context, this.topic)))
    }
            
    // Refresh Cache
    try
    {
      cache.refreshIfExpired
      
      users := cache.getImmediateUsersForInterest(context, interest)
      
      if (users.size > 0)
      {
        this.sendEmail(
          context,
          interest,
          users.mapNotNull | user | { user.email }
        )
      }
    }
    catch (Err error)
    {
      this.log.err("Error sending email topic", error)
    }
    
    return Future.makeCompletable.complete(null)
  }
  
  private TopicCache? cache()
  {
    return ((Unsafe?) this._topicCache.val)?.val
  }
  
  static new wrap(Dict? dict)
  {
    if (dict == null)
      return null
    
    return create(EmailTopicNotificationTarget#, dict)
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using haystack
using hx
using axon

class NotificationFuncs 
{
  @Axon
  public static Void notifyTarget(Obj target, Obj interest)
  {
    context := HxContext.curHx
    
    NotificationLib lib := context.rt.lib("notification")
    
    if (target is Dict)
      target = ((Dict) target).id
    
    lib.notifyManual(context, target, interest)
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using hx
using obs
using concurrent
using haystack

const class NotificationLib : HxLib
{
  public static const Str TARGET_TAG_NAME := "notificationTarget"
  private static const Int RETRY_THRESHOLD := 5 // TODO: Later make configurable as tag on this record
  private static const Duration RETRY_TIME := Duration.fromStr("30sec")
  
  override const NotificationRestAPI web := NotificationRestAPI(this)
  
  private const Unsafe _target := Unsafe([:])
  private const Unsafe _retryQueue := Unsafe([:])
  
  private const ActorPool pool
  private const Actor actor
  
  new make(): super()
  {
    this.pool = ActorPool()
    this.actor = Actor(
      this.pool,
      | NotificationQueueItem item | { this.onHandleNotification(item) }
    )
    
  }
  
  private Ref:NotificationTarget targets()
  {   
    return this._target.val
  }
  
  public override Void onStart()
  {
    this.processTargets
    
    this.observe(
      "obsCommits",
      Etc.makeDict([
        "obsAdds": Marker.val,
        "obsUpdates": Marker.val,
        "obsRemoves": Marker.val,
        "obsFilter": TARGET_TAG_NAME
      ]),
      #noteRecordChangeHandler
    )
    
    super.onStart
  }
  
  public override Void onStop()
  {
    this.pool.stop
    this.pool.join
    super.onStop
  }
  
  protected Obj? onHandleNotification (NotificationQueueItem item)
  {
    if (item.currentTries > RETRY_THRESHOLD)
    {
      this.log.warn(
        "Retrying notifications for $item.interest has exceeded its error threshold."
      )
      
      return null
    }
    
    try
    {
      item.target.execute(
        item.context,
        item.interest
      )
    }
    catch (Err error)
    {
      item.incrementTries
      
      // Add target to retry queue if failed
      this.log.warn("Error exeucting target (attempt $item.currentTries), retrying...", error)
      
      this.actor.sendLater(
        RETRY_TIME,
        item
      )
    }
    
    return null
  }
      
  public Future notify(HxContext context, Str subject, Obj interest)
  {
    // TODO: Make this execute on a separate thread.
    // -- Use coalescing actor to coalesce multiple calls to the same target.
    // -- May need to calculate target
    
    if (interest is Grid && ((Grid) interest).size == 1 && !((Grid) interest).has("digest"))
      interest = ((Grid) interest).get(0)
          
    this.targets.vals.mapNotNull | target | {
      try
      {
        if (target.include(context, interest))
        {
          if (interest is Dict)
            interest = [interest]
          
          return NotificationQueueItem(context, interest, target)
        }
      }
      catch (Err error)
      {
        // Error executing target include
        this.log.warn("Error testing include for notification target", error)
      }     
      
      return null
    }.each | item | {
      actor.send(item)
    }
    
    // Send notification to any connected web sockets.
    this.web.sendNotification(subject, interest)
    
    return Future.makeCompletable.complete(null)
  }
  
  public Future notifyManual(HxContext context, Ref targetRef, Obj interest)
  {
    try
    {
      target := this.targets.get(targetRef)
      return target.execute(context, interest)
    }
    catch (Err err)
    {
      return Future.makeCompletable.completeErr(err)
    }
  }
  
  private Ref:NotificationQueueItem retryQueue()
  {
    return this._retryQueue.val
  } 
    
  private Void noteRecordChangeHandler(CommitObservation observation)
  {
    switch (observation.subType)
    {
      //case CommitObservationAction.added.name:
      //  this.processTarget(observation.newRec)
      
      case CommitObservationAction.updated.name:
        this.targets.remove(observation.id)
      
      case CommitObservationAction.removed.name:
        this.targets.remove(observation.id)
    }
    
    this.processTarget(observation.newRec)
  }
  
  private Void processTargets()
  {
    targets := this.rt.db.readAllList(
      Filter.fromStr(TARGET_TAG_NAME)
    )
    
    targets.each | target | {
      this.processTarget(target)
    }
  }
  
  private Void processTarget(Dict dict)
  {
    type := TARGET_REGISTRY.get(dict.get(TARGET_TAG_NAME))
    
    constructorMethod := type.method("wrap", false)
    
    if (constructorMethod != null)
    {
      targetInst := constructorMethod.call(dict) as NotificationTarget
      this.targets.set(dict.id, targetInst)   
    }
  }
  
  
  private static const Str:Type TARGET_REGISTRY := [
    DirectEmailNotificationTarget.TYPE: DirectEmailNotificationTarget#,
    EmailTopicNotificationTarget.TYPE: EmailTopicNotificationTarget#,
    PushBulletNotificationTarget.TYPE: PushBulletNotificationTarget#
  ]
}

const class NotificationQueueItem
{
  private const AtomicRef _interests := AtomicRef(Unsafe([,]))
  public const NotificationTarget target
  private const AtomicInt tryCount := AtomicInt(0)
  private const Unsafe _context
  
  
  new make (HxContext context, Obj[] interest, NotificationTarget target)
  {
    this.interestInternal.addAll(interest)
    this.target = target
    this._context = Unsafe(context)
  }
  
  public Void incrementTries()
  {
    this.tryCount.add(1)
  }
  
  public Int currentTries()
  {
    return this.tryCount.val
  }
  
  public HxContext context()
  {
    return this._context.val
  }
  
  private Obj[] interestInternal()
  {
    return ((Unsafe)this._interests.val).val
  }
  
  public Obj interest()
  {
    if (this.interestInternal.size == 1)
      return this.interestInternal.get(0)
    
    return this.interestInternal
  }
  
  public Void addInterests(Obj[] interests)
  {
    this.interestInternal.addAll(interests)
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using hx
using web
using util
using haystack

const class NotificationRestAPI : HxLibWeb 
{
  const Unsafe _sockets := Unsafe([,])
  
  override NotificationLib lib()
  {
    return super.lib
  }
  
  new make(NotificationLib lib): super(lib)
  {
  }
  
  private WebSocket[] sockets()
  {
    return this._sockets.val
  }
  
  override Void onGet()
  {
    operation := this.req.modRel.path.getSafe(0, "default")
    context := this.rt.user.authenticate(req, res)
    
    if (operation == "ws")
    {
      server := WebSocket.openServer(req, res)
      this.sockets.add(server)
    }
  }
  
  public Void sendNotification(Str subject, Obj notification)
  {
    if (this.sockets.size < 1)
      return
    
    closedSockets := [,]
    
    // Encode notification to hayson
    dataStr := StrBuf()
    writer := JsonWriter(dataStr.out)
    writer.writeVal(notification)
    writer.close
    
    output := dataStr.toStr
    
    // Send data to each connected socket
    this.sockets.each | socket | 
    {
      if (socket.isClosed)
      {
        closedSockets.add(socket)
        return
      }
      
      try
      {
        socket.send(
          output
        )
      }
      catch (Err error)
      {
        socket.close()
        closedSockets.add(socket)
      }
    }
    
    // Remove sockets that have closed
    closedSockets.each | socket | {  
      this.sockets.remove(socket)
    }
  }
  
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using haystack
using hx
using concurrent

abstract const class NotificationTarget : TypedDict
{
  protected const Log log := Log.get("NotificationTarget-${typeof.name}")
  // Notification Type
  @TypedTag
  const Str notificationTarget
  
  // Notification filter. This filter should select the alarm conditions and sourceRef->tags
  @TypedTag
  const Str? notificationOn
  
  @TypedTag
  const Bool disabled
    
  const Filter? onFilter
  
  new make(Dict rec, |This| f): super(rec)
  {
    f(this)
    
    if (this.notificationOn != null)
      this.onFilter = Filter.fromStr(this.notificationOn)
  }
  
  public abstract Future execute(HxContext context, Obj interest)
  
  public Bool include(HxContext context, Obj interest)
  {
    if (this.disabled)
      return false
    
    if (this.onFilter != null)
    {
      if (interest is List)
      {
        return ((List) interest).all | item | {  
          this.onFilter.matches(item, context)
        }
      }
      
      if (interest is Grid)
      {
        return ((Grid) interest).all | row | {
          this.onFilter.matches(row, context)
        }
      }

      if (interest is Dict)
        return this.onFilter.matches(interest, context)
      
      return false
    }
    
    return true
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using concurrent
using haystack
using hx

const class PushBulletNotificationTarget : NotificationTarget
{
  public static const Str TYPE := "pushBullet"
  
  new make(Dict rec, |This| f): super(rec, f) {}
  
  public override Future execute(HxContext context, Obj interest)
  {
    return Future.makeCompletable.completeErr(Err("Not implemented"))
  }
  
  static new wrap(Dict? dict)
  {
    if (dict == null)
      return null
    
    return create(PushBulletNotificationTarget#, dict)
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST


using haystack
using hx
using skyarcd

class TopicCache 
{
  static const Duration CACHE_PERIOD := 27sec
  
  const Str topicName
  
  private const Log log
  private HxContext context
  private Ref:Topic topics
  private Int lastRefreshed // Last Refreshed Ticks
  
  new make(HxContext context, Str topicName)
  {
    this.context = context
    this.topicName = topicName
    this.log = Log.get("TopicCache")
    this.topics = [:]
  }
  
  public Bool isExpired()
  {
    return (Duration.nowTicks - this.lastRefreshed) > CACHE_PERIOD.ticks
  }
  
  public Void refreshIfExpired()
  {
    if (this.isExpired)
      this.refresh()
  }
  
  private Void refresh()
  {
    this.lastRefreshed = Duration.nowTicks
    
    // Get Topics
    this.topics.clear
    
    topics := context.db.readAllList(
      Filter.fromStr("${this.topicName}Topic and topicOn")
    ).map | rec | { 
      Topic.wrap(rec) 
    }
    
    topics.each | Topic topic | { 
      this.topics.set(topic.id, topic) 
    } 
  
    // Populate users in topic
    UserMod userMod := context.rt.lib("user")
    
    userMod.users.each | user | 
    {
      if (user.isDisabled || user.email == null)
        return
      
      // Populate Topics to send immediately
      if (user.has("topicsEmailImmediate"))
      {
        try
        {
          topicIds := Ref.listFromStr(
            user.meta.get("topicsEmailImmediate")
          )
          
          topicIds.each | topicId | {  
            this.topics.getChecked(topicId, false)?.addImmediate(EmailUser(user))
          }
        }
        catch (Err err)
        {
          // Invalid Immediate Ids
          log.err("Invalid immediate ids", err)
        }
      }
      
      // Populate topics to send as a digest
      if (user.has("topicsEmailDigest"))
      {
        try
        {
          topicIds := Ref.listFromStr(
            user.meta.get("topicsEmailDigest")
          )
          
          topicIds.each | topicId | {  
            this.topics.getChecked(topicId, false)?.addDigest(EmailUser(user))
          }
        }
        catch (Err err)
        {
          // Invalid Digest Ids
          log.err("Invalid digest ids", err)
        }
      }
    }
  }
  
  public HxUser[] getImmediateUsersForInterest(HxContext context, Obj interest)
  {
    // TODO: Ensure the email isn't in the list twice.
    Dict[]? interests
    
    if (interest is Grid)
      interests = ((Grid) interest).toRows
    else
    if (interest is List)
      interests = interest
    if (interest is Dict)
      interests = [interest]
    
    
    Topic[] matchedTopics := interests.flatMap | item | {
      return this.topics.vals.findAll | topic | {
        topic.on.matches(item, context)
      }
    }   
    
    currentTime := DateTime.now.time
    
    return matchedTopics.flatMap | topic | {  
      topic.immediateUsers
        .mapNotNull | user| {  
          if (user.shouldSilenceEmail(currentTime))
            return null
          
          return user.user
        }
    }
  }
  
  public HxUser[] getDigestUsersForInterest(HxContext context, Obj interest)
  {
    Dict[]? interests
    
    if (interest is Grid)
      interests = ((Grid) interest).toRows
    else
    if (interest is List)
      interests = interest
    if (interest is Dict)
      interests = [interest]
    
    
    Topic[] matchedTopics := interests.flatMap | item | {
      return this.topics.vals.findAll | topic | {
        topic.on.matches(item, context)
      }
    }
    
    currentTime := DateTime.now.time
    
    return matchedTopics.flatMap | topic | {  
      topic.digestUsers
        .mapNotNull | user| {  
          if (user.shouldSilenceEmail(currentTime))
            return null
          
          return user.user
        }
    }
  }
}

const class Topic : TypedDict
{
  @TypedTag
  const Str topicOn
  
  const Filter on
  
  private const Unsafe _immediateUsers
  private const Unsafe _digestUsers
  
  new make(Dict rec, | This | f): super(rec)
  {
    f(this)
    
    this.on = Filter.fromStr(this.topicOn)
    this._immediateUsers = Unsafe([,])
    this._digestUsers = Unsafe([,])
  }
  
  public EmailUser[]? immediateUsers()
  {
    return this._immediateUsers.val
  }
  
  public EmailUser[]? digestUsers()
  {
    return this._immediateUsers.val
  }
  
  public Void addImmediate(EmailUser user)
  {
    this.immediateUsers.add(user)
  }
  
  public Void addDigest(EmailUser user)
  {
    this.digestUsers.add(user)
  }
  
  static new wrap(Dict dict)
  {
    return create(Topic#, dict, | err | { echo("Error creating topic: $err") })
  }
}

const class EmailUser
{
  const HxUser user
  const Time? silenceStartTime
  const Time? silenceEndTime
  
  new make(HxUser user)
  {
    this.user = user
    
    if (user.meta.has("emailSilenceStartTime"))
      this.silenceStartTime = user.meta.get("emailSilenceStartTime")
    
    if (user.meta.has("emailSilenceEndTime"))
      this.silenceEndTime = user.meta.get("emailSilenceEndTime")
  }
  
  public Bool shouldSilenceEmail(Time when)
  {
    if (this.silenceStartTime == null)
      return false
    
    isSilenced := false
    
    if (when.compare(this.silenceStartTime) == 1)
    {
      isSilenced = true
      
      if (this.silenceEndTime != null)
      {
        if (when.compare(this.silenceEndTime) == 1)
          isSilenced = false
      }
    }
    
    
    return isSilenced
  }
}

Skyspark 3.1.12 ∙ 20-Oct-2026 14:19 CEST