635 lines
16 KiB
Go
635 lines
16 KiB
Go
// svxdr — live-data daemon for Reflector Dashboard (nodelist + proxy + astmeta + xlink)
|
|
// Copyright (C) 2026 SA7AUX (Björn)
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it under
|
|
// the terms of the GNU Affero General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option) any
|
|
// later version. See the LICENSE file for details.
|
|
|
|
// Package astmeta is the svxdr astmeta subsystem — AllStar talker metadata,
|
|
// folded in from the standalone svx-web/astmeta. It polls AMI (rpt xnode) for
|
|
// keyed remote nodes, resolves callsign/system, feeds svxlink UsrpLogic (USRP),
|
|
// optionally MQTT, and POSTs talker-meta to the nodelist/proxy (kept for the
|
|
// REMOTE case, e.g. astmeta on the allstar box; when co-located a later phase
|
|
// shares the store directly). Logic preserved exactly, including the fan-out
|
|
// allstar remap. Config now comes from svxdr.json (astmeta + nodes + redis).
|
|
package astmeta
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
mqtt "github.com/eclipse/paho.mqtt.golang"
|
|
"github.com/redis/go-redis/v9"
|
|
|
|
"svxportal/svxdr/config"
|
|
)
|
|
|
|
// settings holds the astmeta-relevant config, derived from svxdr.json.
|
|
type settings struct {
|
|
AmiHost, AmiPort, AmiUser, AmiSecret string
|
|
IgnoreNodes map[string]bool
|
|
PollInterval, HangTime time.Duration
|
|
Network string
|
|
|
|
Target, Format, SpaceReplace string
|
|
RefreshInterval time.Duration
|
|
|
|
LookupEnabled bool
|
|
LookupURL, LookupFile string
|
|
CacheTTL time.Duration
|
|
RedisCache bool
|
|
RedisPrefix string
|
|
Redis config.Redis
|
|
|
|
MqttEnabled bool
|
|
MqttBroker, MqttUser, MqttPass, MqttPrefix string
|
|
|
|
NodelistURL, NodelistToken string
|
|
NodelistPeers []metaTarget
|
|
Via string
|
|
|
|
// per-node, from cfg.Nodes
|
|
WatchNodes []string
|
|
NodeTg map[string]string
|
|
NodeVia map[string]string
|
|
NodeAllstar map[string]string
|
|
Names map[string]string
|
|
ownNodes map[string]bool
|
|
}
|
|
|
|
func (s *settings) tgFor(wn string) string { return s.NodeTg[wn] }
|
|
func (s *settings) viaFor(wn string) string {
|
|
if v := s.NodeVia[wn]; v != "" {
|
|
return v
|
|
}
|
|
return s.Via
|
|
}
|
|
|
|
func newSettings(c *config.Config) *settings {
|
|
a := c.Astmeta
|
|
s := &settings{
|
|
AmiHost: orDef(a.AmiHost, "127.0.0.1"), AmiPort: orDef(a.AmiPort, "5038"),
|
|
AmiUser: a.AmiUser, AmiSecret: a.AmiSecret,
|
|
IgnoreNodes: map[string]bool{},
|
|
PollInterval: orDur(a.PollInterval.D(), time.Second),
|
|
HangTime: a.HangTime.D(),
|
|
Network: orDef(a.Network, "AllStar"),
|
|
Target: a.UsrpTarget, // empty = USRP sink disabled (newSender returns nil)
|
|
Format: orDef(a.Format, "info"),
|
|
SpaceReplace: orDef(a.SpaceReplace, "-"),
|
|
RefreshInterval: a.RefreshInterval.D(),
|
|
LookupEnabled: a.LookupEnabled,
|
|
LookupURL: a.LookupURL, LookupFile: a.LookupFile,
|
|
CacheTTL: orDur(a.CacheTTL.D(), 24*time.Hour),
|
|
RedisCache: a.RedisCache,
|
|
RedisPrefix: orDef(a.RedisCachePrefix, "astmeta:node:"),
|
|
Redis: c.Redis,
|
|
MqttEnabled: a.MqttEnabled, MqttBroker: a.MqttBroker, MqttUser: a.MqttUser,
|
|
MqttPass: a.MqttPass, MqttPrefix: a.MqttPrefix,
|
|
NodelistURL: a.NodelistURL, NodelistToken: a.NodelistToken,
|
|
NodeTg: map[string]string{},
|
|
NodeVia: map[string]string{},
|
|
NodeAllstar: map[string]string{},
|
|
Names: map[string]string{},
|
|
ownNodes: map[string]bool{},
|
|
}
|
|
s.Via = "" // global via fallback is unused now that nodes carry their own via
|
|
for _, n := range a.IgnoreNodes {
|
|
if n = strings.TrimSpace(n); n != "" {
|
|
s.IgnoreNodes[n] = true
|
|
}
|
|
}
|
|
for _, p := range a.NodelistPeers {
|
|
if p.URL != "" {
|
|
s.NodelistPeers = append(s.NodelistPeers, metaTarget{URL: p.URL, Token: p.Token})
|
|
}
|
|
}
|
|
// per-node bindings from nodes[] (the source of truth)
|
|
for _, e := range c.Nodes {
|
|
if !e.IsEnabled() || e.SvxNode == 0 {
|
|
continue
|
|
}
|
|
wn := strconv.Itoa(e.SvxNode)
|
|
s.WatchNodes = append(s.WatchNodes, wn)
|
|
s.ownNodes[wn] = true
|
|
if e.TG != 0 {
|
|
s.NodeTg[wn] = strconv.Itoa(e.TG)
|
|
}
|
|
via := e.Via
|
|
if via == "" {
|
|
via = e.Callsign
|
|
}
|
|
if via != "" {
|
|
s.NodeVia[wn] = via
|
|
}
|
|
if e.Allstar != 0 {
|
|
s.NodeAllstar[wn] = strconv.Itoa(e.Allstar)
|
|
if e.System != "" {
|
|
s.Names[strconv.Itoa(e.Allstar)] = e.System
|
|
}
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
func orDef(v, d string) string {
|
|
if v == "" {
|
|
return d
|
|
}
|
|
return v
|
|
}
|
|
func orDur(v, d time.Duration) time.Duration {
|
|
if v <= 0 {
|
|
return d
|
|
}
|
|
return v
|
|
}
|
|
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
|
t := time.NewTimer(d)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return false
|
|
case <-t.C:
|
|
return true
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- AMI --------
|
|
|
|
type ami struct {
|
|
s *settings
|
|
conn net.Conn
|
|
rd *bufio.Reader
|
|
}
|
|
|
|
func (a *ami) connect() error {
|
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(a.s.AmiHost, a.s.AmiPort), 5*time.Second)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
a.conn = conn
|
|
a.rd = bufio.NewReader(conn)
|
|
a.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
if _, err := a.rd.ReadString('\n'); err != nil {
|
|
conn.Close()
|
|
return fmt.Errorf("ami banner: %w", err)
|
|
}
|
|
resp, err := a.request(fmt.Sprintf(
|
|
"Action: Login\r\nUsername: %s\r\nSecret: %s\r\nEvents: off\r\n\r\n",
|
|
a.s.AmiUser, a.s.AmiSecret))
|
|
if err != nil {
|
|
conn.Close()
|
|
return err
|
|
}
|
|
if !strings.Contains(resp, "Success") {
|
|
conn.Close()
|
|
return fmt.Errorf("ami login failed: %s", strings.TrimSpace(resp))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *ami) request(action string) (string, error) {
|
|
a.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
|
if _, err := io.WriteString(a.conn, action); err != nil {
|
|
return "", err
|
|
}
|
|
var b strings.Builder
|
|
follows := false
|
|
for {
|
|
a.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
|
|
line, err := a.rd.ReadString('\n')
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
b.WriteString(line)
|
|
t := strings.TrimRight(line, "\r\n")
|
|
if strings.HasPrefix(t, "Response: Follows") {
|
|
follows = true
|
|
}
|
|
if follows {
|
|
if strings.Contains(t, "--END COMMAND--") {
|
|
follows = false
|
|
}
|
|
continue
|
|
}
|
|
if t == "" {
|
|
return b.String(), nil
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *ami) alinks(node string) ([]string, error) {
|
|
resp, err := a.request(fmt.Sprintf("Action: Command\r\nCommand: rpt xnode %s\r\n\r\n", node))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, line := range strings.Split(resp, "\n") {
|
|
line = strings.TrimPrefix(strings.TrimSpace(line), "Output: ")
|
|
if v, ok := strings.CutPrefix(line, "RPT_ALINKS="); ok {
|
|
parts := strings.Split(strings.TrimSpace(v), ",")
|
|
if len(parts) > 1 {
|
|
return parts[1:], nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("no RPT_ALINKS in xnode output for %s", node)
|
|
}
|
|
|
|
// ------------------------------------------------------------- resolver ------
|
|
|
|
var astdbLine = regexp.MustCompile(`^([0-9]+)\|([^|]*)\|([^|]*)\|(.*)$`)
|
|
|
|
type resolver struct {
|
|
s *settings
|
|
ctx context.Context
|
|
rdb *redis.Client
|
|
mu sync.RWMutex
|
|
astdb map[string][2]string
|
|
last time.Time
|
|
}
|
|
|
|
func newResolver(ctx context.Context, s *settings) *resolver {
|
|
r := &resolver{s: s, ctx: ctx, astdb: map[string][2]string{}}
|
|
if s.RedisCache {
|
|
opt := &redis.Options{Password: s.Redis.Password, DB: s.Redis.DB}
|
|
if s.Redis.Socket != "" {
|
|
opt.Network, opt.Addr = "unix", s.Redis.Socket
|
|
} else {
|
|
opt.Addr = orDef(s.Redis.Addr, "127.0.0.1:6379")
|
|
}
|
|
r.rdb = redis.NewClient(opt)
|
|
}
|
|
return r
|
|
}
|
|
|
|
func (r *resolver) refresh() {
|
|
if !r.s.LookupEnabled || time.Since(r.last) < r.s.CacheTTL {
|
|
return
|
|
}
|
|
var rd io.ReadCloser
|
|
var err error
|
|
switch {
|
|
case r.s.LookupFile != "":
|
|
rd, err = os.Open(r.s.LookupFile)
|
|
case r.s.LookupURL != "":
|
|
var resp *http.Response
|
|
cl := &http.Client{Timeout: 60 * time.Second}
|
|
resp, err = cl.Get(r.s.LookupURL)
|
|
if err == nil {
|
|
rd = resp.Body
|
|
}
|
|
default:
|
|
r.last = time.Now()
|
|
return
|
|
}
|
|
if err != nil {
|
|
log.Printf("astmeta lookup: refresh failed: %v", err)
|
|
r.last = time.Now().Add(-r.s.CacheTTL + 5*time.Minute)
|
|
return
|
|
}
|
|
defer rd.Close()
|
|
m := map[string][2]string{}
|
|
sc := bufio.NewScanner(rd)
|
|
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
|
|
for sc.Scan() {
|
|
if f := astdbLine.FindStringSubmatch(sc.Text()); f != nil {
|
|
m[f[1]] = [2]string{f[2], f[3]}
|
|
}
|
|
}
|
|
if len(m) > 0 {
|
|
r.mu.Lock()
|
|
r.astdb = m
|
|
r.mu.Unlock()
|
|
log.Printf("astmeta lookup: astdb loaded, %d nodes", len(m))
|
|
}
|
|
r.last = time.Now()
|
|
}
|
|
|
|
func (r *resolver) Resolve(node string) (call, system string) {
|
|
system = r.s.Names[node]
|
|
call = node
|
|
if r.rdb != nil {
|
|
if v, err := r.rdb.Get(r.ctx, r.s.RedisPrefix+node).Result(); err == nil && v != "" {
|
|
return v, system
|
|
}
|
|
}
|
|
r.refresh()
|
|
r.mu.RLock()
|
|
e, ok := r.astdb[node]
|
|
r.mu.RUnlock()
|
|
if ok && e[0] != "" {
|
|
if r.rdb != nil {
|
|
r.rdb.Set(r.ctx, r.s.RedisPrefix+node, e[0], r.s.CacheTTL)
|
|
}
|
|
call = e[0]
|
|
}
|
|
return call, system
|
|
}
|
|
|
|
// ---------------------------------------------------------------- sender -----
|
|
|
|
type sender struct {
|
|
s *settings
|
|
conn net.Conn
|
|
seq uint32
|
|
}
|
|
|
|
func newSender(s *settings) (*sender, error) {
|
|
if s.Target == "" {
|
|
return nil, nil // USRP sink optional
|
|
}
|
|
conn, err := net.Dial("udp", s.Target)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &sender{s: s, conn: conn}, nil
|
|
}
|
|
|
|
func (s *sender) header(utype uint32) []byte {
|
|
h := make([]byte, 32)
|
|
copy(h[0:4], "USRP")
|
|
binary.LittleEndian.PutUint32(h[4:8], s.seq)
|
|
binary.LittleEndian.PutUint32(h[20:24], utype)
|
|
s.seq++
|
|
return h
|
|
}
|
|
|
|
func (s *sender) SendInfo(call, node, tg string) error {
|
|
payload := map[string]any{
|
|
"ab": map[string]string{"version": "astmeta"},
|
|
"digital": map[string]string{
|
|
"gw": node, "rpt": node, "tg": tg, "ts": "1", "cc": "1",
|
|
"call": call, "ambe_mode": "USRP",
|
|
},
|
|
"last_tune": tg,
|
|
}
|
|
j, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
frame := append(s.header(2), []byte("INFO:")...)
|
|
frame = append(frame, j...)
|
|
_, err = s.conn.Write(frame)
|
|
return err
|
|
}
|
|
|
|
func (s *sender) SendTlv(call, node, tg string) error {
|
|
if len(call) > 8 {
|
|
call = call[:8]
|
|
}
|
|
tgn, _ := strconv.Atoi(tg)
|
|
nodeNum, _ := strconv.Atoi(node)
|
|
body := make([]byte, 2+3+4+3+1+1+306)
|
|
body[0] = 0x08
|
|
body[1] = byte(len(call) + 13)
|
|
binary.LittleEndian.PutUint32(body[5:9], uint32(nodeNum))
|
|
body[9] = byte(tgn >> 16)
|
|
body[10] = byte(tgn >> 8)
|
|
body[11] = byte(tgn)
|
|
body[12] = 1
|
|
body[13] = 1
|
|
copy(body[14:], call)
|
|
frame := append(s.header(2), body...)
|
|
_, err := s.conn.Write(frame)
|
|
return err
|
|
}
|
|
|
|
func (s *sender) Send(call, node, tg string) error {
|
|
call = strings.Join(strings.Fields(call), s.s.SpaceReplace)
|
|
if s.s.Format == "tlv" {
|
|
return s.SendTlv(call, node, tg)
|
|
}
|
|
return s.SendInfo(call, node, tg)
|
|
}
|
|
|
|
// ------------------------------------------------------------- publisher -----
|
|
|
|
type publisher struct {
|
|
s *settings
|
|
cl mqtt.Client
|
|
}
|
|
|
|
func newPublisher(s *settings) *publisher {
|
|
if !s.MqttEnabled {
|
|
return nil
|
|
}
|
|
opts := mqtt.NewClientOptions().
|
|
AddBroker(s.MqttBroker).SetClientID("svxdr-astmeta").
|
|
SetUsername(s.MqttUser).SetPassword(s.MqttPass).
|
|
SetAutoReconnect(true).SetConnectRetry(true).
|
|
SetConnectRetryInterval(5 * time.Second)
|
|
cl := mqtt.NewClient(opts)
|
|
if t := cl.Connect(); t.Wait() && t.Error() != nil {
|
|
log.Printf("astmeta mqtt: connect failed: %v (will keep retrying)", t.Error())
|
|
}
|
|
return &publisher{s: s, cl: cl}
|
|
}
|
|
|
|
func (p *publisher) PublishMeta(call, node, system, tg, via, network string) {
|
|
topic := strings.TrimRight(p.s.MqttPrefix, "/") + "/talker/" + tg + "/meta"
|
|
j, _ := json.Marshal(map[string]any{
|
|
"callsign": call, "node": node, "system": system, "via": via,
|
|
"network": network, "ts": time.Now().UnixMilli(),
|
|
})
|
|
p.cl.Publish(topic, 0, false, j)
|
|
}
|
|
|
|
// --------------------------------------------------------------- poster ------
|
|
|
|
type metaTarget struct{ URL, Token string }
|
|
|
|
type poster struct {
|
|
targets []metaTarget
|
|
cl *http.Client
|
|
}
|
|
|
|
func newPoster(s *settings) *poster {
|
|
var targets []metaTarget
|
|
if s.NodelistURL != "" && s.NodelistToken != "" {
|
|
targets = append(targets, metaTarget{URL: s.NodelistURL, Token: s.NodelistToken})
|
|
}
|
|
targets = append(targets, s.NodelistPeers...)
|
|
if len(targets) == 0 {
|
|
return nil
|
|
}
|
|
return &poster{targets: targets, cl: &http.Client{Timeout: 5 * time.Second}}
|
|
}
|
|
|
|
func (p *poster) PostMeta(call, node, system, event string, tg int, via, network string) {
|
|
if tg <= 0 {
|
|
return
|
|
}
|
|
body, _ := json.Marshal(map[string]any{
|
|
"tg": tg, "event": event, "callsign": call, "node": node,
|
|
"system": system, "via": via, "network": network,
|
|
})
|
|
for _, t := range p.targets {
|
|
req, err := http.NewRequest(http.MethodPost, t.URL, bytes.NewReader(body))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if t.Token != "" {
|
|
req.Header.Set("X-Ingest-Token", t.Token)
|
|
}
|
|
resp, err := p.cl.Do(req)
|
|
if err != nil {
|
|
log.Printf("astmeta nodelist: post to %s failed: %v", t.URL, err)
|
|
continue
|
|
}
|
|
resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
log.Printf("astmeta nodelist: post %s to %s returned HTTP %d", event, t.URL, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ Run ------
|
|
|
|
var alinkEntry = regexp.MustCompile(`^([0-9]+)([A-Z]+)$`)
|
|
|
|
// Run polls AMI for every watched node and emits talker metadata until ctx ends.
|
|
func Run(ctx context.Context, c *config.Config) error {
|
|
s := newSettings(c)
|
|
if len(s.WatchNodes) == 0 {
|
|
return fmt.Errorf("astmeta: no nodes to watch (nodes[] empty)")
|
|
}
|
|
if s.AmiUser == "" || s.AmiSecret == "" {
|
|
return fmt.Errorf("astmeta: ami_user/ami_secret required")
|
|
}
|
|
resolver := newResolver(ctx, s)
|
|
snd, err := newSender(s)
|
|
if err != nil {
|
|
return fmt.Errorf("astmeta usrp: %w", err)
|
|
}
|
|
pub := newPublisher(s)
|
|
post := newPoster(s)
|
|
log.Printf("astmeta: watching %v via AMI %s:%s (usrp=%v mqtt=%v post=%v)",
|
|
s.WatchNodes, s.AmiHost, s.AmiPort, snd != nil, pub != nil, post != nil)
|
|
|
|
a := &ami{s: s}
|
|
keyed := map[string]bool{}
|
|
unkeyedAt := map[string]time.Time{}
|
|
lastSent := map[string]time.Time{}
|
|
|
|
for {
|
|
if ctx.Err() != nil {
|
|
return nil
|
|
}
|
|
if a.conn == nil {
|
|
if err := a.connect(); err != nil {
|
|
log.Printf("astmeta ami: %v — retrying in 10s", err)
|
|
if !sleepCtx(ctx, 10*time.Second) {
|
|
return nil
|
|
}
|
|
continue
|
|
}
|
|
log.Printf("astmeta ami: connected")
|
|
}
|
|
if !sleepCtx(ctx, s.PollInterval) {
|
|
return nil
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
fail := false
|
|
for _, wn := range s.WatchNodes {
|
|
tg := s.tgFor(wn)
|
|
via := s.viaFor(wn)
|
|
tgNum, _ := strconv.Atoi(tg)
|
|
|
|
entries, err := a.alinks(wn)
|
|
if err != nil {
|
|
log.Printf("astmeta ami: %v — reconnecting", err)
|
|
a.conn.Close()
|
|
a.conn = nil
|
|
fail = true
|
|
break
|
|
}
|
|
for _, e := range entries {
|
|
f := alinkEntry.FindStringSubmatch(e)
|
|
if f == nil {
|
|
continue
|
|
}
|
|
node, flags := f[1], f[2]
|
|
if s.IgnoreNodes[node] {
|
|
continue
|
|
}
|
|
reportNode := node
|
|
if static := s.NodeAllstar[wn]; static != "" && node != static && s.ownNodes[node] {
|
|
reportNode = static
|
|
}
|
|
k := wn + "|" + node
|
|
isKeyed := strings.HasSuffix(flags, "K")
|
|
seen[k] = true
|
|
refresh := s.RefreshInterval > 0 && time.Since(lastSent[k]) >= s.RefreshInterval
|
|
if isKeyed {
|
|
delete(unkeyedAt, k)
|
|
if !keyed[k] || refresh {
|
|
call, system := resolver.Resolve(reportNode)
|
|
display := system
|
|
if display == "" {
|
|
display = call
|
|
}
|
|
if snd != nil {
|
|
if err := snd.Send(display, reportNode, tg); err != nil {
|
|
log.Printf("astmeta usrp: send failed: %v", err)
|
|
}
|
|
}
|
|
if !keyed[k] {
|
|
log.Printf("astmeta talker start: node %s call %s system %q tg %s via %s",
|
|
reportNode, call, system, tg, via)
|
|
}
|
|
if pub != nil {
|
|
pub.PublishMeta(call, reportNode, system, tg, via, s.Network)
|
|
}
|
|
if post != nil {
|
|
post.PostMeta(call, reportNode, system, "start", tgNum, via, s.Network)
|
|
}
|
|
lastSent[k] = time.Now()
|
|
}
|
|
keyed[k] = true
|
|
} else if keyed[k] {
|
|
if _, pending := unkeyedAt[k]; !pending {
|
|
unkeyedAt[k] = time.Now()
|
|
}
|
|
if s.HangTime <= 0 || time.Since(unkeyedAt[k]) >= s.HangTime {
|
|
log.Printf("astmeta talker stop: node %s tg %s", reportNode, tg)
|
|
if post != nil {
|
|
post.PostMeta("", reportNode, "", "stop", tgNum, via, s.Network)
|
|
}
|
|
keyed[k] = false
|
|
delete(unkeyedAt, k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !fail {
|
|
for k := range keyed {
|
|
if !seen[k] {
|
|
delete(keyed, k)
|
|
delete(lastSent, k)
|
|
delete(unkeyedAt, k)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|