typeclaw 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +134 -0
- package/auth.schema.json +63 -0
- package/cron.schema.json +96 -0
- package/package.json +72 -0
- package/scripts/emit-base-dockerfile.ts +5 -0
- package/scripts/generate-schema.ts +34 -0
- package/secrets.schema.json +63 -0
- package/src/agent/auth.ts +119 -0
- package/src/agent/compaction.ts +35 -0
- package/src/agent/git-nudge.ts +95 -0
- package/src/agent/index.ts +451 -0
- package/src/agent/plugin-tools.ts +269 -0
- package/src/agent/reload-tool.ts +71 -0
- package/src/agent/self.ts +45 -0
- package/src/agent/session-origin.ts +288 -0
- package/src/agent/subagents.ts +253 -0
- package/src/agent/system-prompt.ts +68 -0
- package/src/agent/tools/channel-fetch-attachment.ts +118 -0
- package/src/agent/tools/channel-history.ts +119 -0
- package/src/agent/tools/channel-reply.ts +182 -0
- package/src/agent/tools/channel-send.ts +212 -0
- package/src/agent/tools/ddg.ts +218 -0
- package/src/agent/tools/restart.ts +122 -0
- package/src/agent/tools/stream-snapshot.ts +181 -0
- package/src/agent/tools/webfetch/fetch.ts +102 -0
- package/src/agent/tools/webfetch/index.ts +1 -0
- package/src/agent/tools/webfetch/strategies/grep.ts +70 -0
- package/src/agent/tools/webfetch/strategies/jq.ts +31 -0
- package/src/agent/tools/webfetch/strategies/raw.ts +3 -0
- package/src/agent/tools/webfetch/strategies/readability.ts +30 -0
- package/src/agent/tools/webfetch/strategies/selector.ts +41 -0
- package/src/agent/tools/webfetch/strategies/snapshot.ts +135 -0
- package/src/agent/tools/webfetch/tool.ts +281 -0
- package/src/agent/tools/webfetch/types.ts +33 -0
- package/src/agent/tools/websearch.ts +96 -0
- package/src/agent/tools/wikipedia.ts +52 -0
- package/src/bundled-plugins/agent-browser/dashboard-discovery.ts +170 -0
- package/src/bundled-plugins/agent-browser/dashboard-proxy.ts +421 -0
- package/src/bundled-plugins/agent-browser/index.ts +179 -0
- package/src/bundled-plugins/agent-browser/shim-install.ts +158 -0
- package/src/bundled-plugins/agent-browser/shim.ts +152 -0
- package/src/bundled-plugins/agent-browser/skills/agent-browser/SKILL.md +113 -0
- package/src/bundled-plugins/guard/index.ts +26 -0
- package/src/bundled-plugins/guard/policies/non-workspace-write.ts +98 -0
- package/src/bundled-plugins/guard/policies/skill-authoring.ts +185 -0
- package/src/bundled-plugins/guard/policies/uncommitted-changes.ts +85 -0
- package/src/bundled-plugins/guard/policy.ts +18 -0
- package/src/bundled-plugins/memory/README.md +71 -0
- package/src/bundled-plugins/memory/append-tool.ts +84 -0
- package/src/bundled-plugins/memory/dreaming-state.ts +86 -0
- package/src/bundled-plugins/memory/dreaming.ts +470 -0
- package/src/bundled-plugins/memory/fragment-parser.ts +67 -0
- package/src/bundled-plugins/memory/index.ts +238 -0
- package/src/bundled-plugins/memory/load-memory.ts +122 -0
- package/src/bundled-plugins/memory/memory-logger.ts +257 -0
- package/src/bundled-plugins/memory/secret-detector.ts +49 -0
- package/src/bundled-plugins/memory/watermark.ts +15 -0
- package/src/bundled-plugins/security/index.ts +35 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +120 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +167 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +488 -0
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +99 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +127 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +86 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +196 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +81 -0
- package/src/bundled-plugins/security/policy.ts +9 -0
- package/src/channels/adapters/discord-bot-channel-resolver.ts +77 -0
- package/src/channels/adapters/discord-bot-classify.ts +148 -0
- package/src/channels/adapters/discord-bot.ts +640 -0
- package/src/channels/adapters/kakaotalk-author-resolver.ts +78 -0
- package/src/channels/adapters/kakaotalk-channel-resolver.ts +105 -0
- package/src/channels/adapters/kakaotalk-classify.ts +77 -0
- package/src/channels/adapters/kakaotalk.ts +622 -0
- package/src/channels/adapters/slack-bot-author-resolver.ts +80 -0
- package/src/channels/adapters/slack-bot-channel-resolver.ts +84 -0
- package/src/channels/adapters/slack-bot-classify.ts +213 -0
- package/src/channels/adapters/slack-bot-dedupe.ts +51 -0
- package/src/channels/adapters/slack-bot-time.ts +10 -0
- package/src/channels/adapters/slack-bot.ts +881 -0
- package/src/channels/adapters/telegram-bot-classify.ts +155 -0
- package/src/channels/adapters/telegram-bot-format.ts +309 -0
- package/src/channels/adapters/telegram-bot.ts +604 -0
- package/src/channels/engagement.ts +227 -0
- package/src/channels/index.ts +21 -0
- package/src/channels/manager.ts +292 -0
- package/src/channels/membership-cache.ts +116 -0
- package/src/channels/membership-from-history.ts +53 -0
- package/src/channels/membership.ts +30 -0
- package/src/channels/participants.ts +47 -0
- package/src/channels/persistence.ts +209 -0
- package/src/channels/reloadable.ts +28 -0
- package/src/channels/router.ts +1570 -0
- package/src/channels/schema.ts +273 -0
- package/src/channels/types.ts +160 -0
- package/src/cli/channel.ts +403 -0
- package/src/cli/compose-status.ts +95 -0
- package/src/cli/compose.ts +240 -0
- package/src/cli/hostd.ts +163 -0
- package/src/cli/index.ts +27 -0
- package/src/cli/init.ts +592 -0
- package/src/cli/logs.ts +38 -0
- package/src/cli/reload.ts +68 -0
- package/src/cli/restart.ts +66 -0
- package/src/cli/run.ts +77 -0
- package/src/cli/shell.ts +33 -0
- package/src/cli/start.ts +57 -0
- package/src/cli/status.ts +178 -0
- package/src/cli/stop.ts +31 -0
- package/src/cli/tui.ts +35 -0
- package/src/cli/ui.ts +110 -0
- package/src/commands/index.ts +74 -0
- package/src/compose/discover.ts +43 -0
- package/src/compose/index.ts +25 -0
- package/src/compose/logs.ts +162 -0
- package/src/compose/restart.ts +69 -0
- package/src/compose/start.ts +62 -0
- package/src/compose/status.ts +28 -0
- package/src/compose/stop.ts +43 -0
- package/src/config/config.ts +424 -0
- package/src/config/index.ts +25 -0
- package/src/config/providers.ts +234 -0
- package/src/config/reloadable.ts +47 -0
- package/src/container/index.ts +27 -0
- package/src/container/logs.ts +37 -0
- package/src/container/port.ts +137 -0
- package/src/container/shared.ts +290 -0
- package/src/container/shell.ts +58 -0
- package/src/container/start.ts +670 -0
- package/src/container/status.ts +76 -0
- package/src/container/stop.ts +120 -0
- package/src/container/verify-running.ts +149 -0
- package/src/cron/consumer.ts +138 -0
- package/src/cron/index.ts +54 -0
- package/src/cron/reloadable.ts +64 -0
- package/src/cron/scheduler.ts +200 -0
- package/src/cron/schema.ts +96 -0
- package/src/hostd/client.ts +113 -0
- package/src/hostd/daemon.ts +587 -0
- package/src/hostd/index.ts +25 -0
- package/src/hostd/paths.ts +82 -0
- package/src/hostd/portbroker-manager.ts +101 -0
- package/src/hostd/protocol.ts +48 -0
- package/src/hostd/spawn.ts +224 -0
- package/src/hostd/supervisor.ts +60 -0
- package/src/hostd/tailscale.ts +172 -0
- package/src/hostd/version.ts +115 -0
- package/src/init/dockerfile.ts +327 -0
- package/src/init/ensure-deps.ts +152 -0
- package/src/init/gitignore.ts +46 -0
- package/src/init/hatching.ts +60 -0
- package/src/init/index.ts +786 -0
- package/src/init/kakaotalk-auth.ts +114 -0
- package/src/init/models-dev.ts +130 -0
- package/src/init/oauth-login.ts +74 -0
- package/src/init/packagejson.ts +94 -0
- package/src/init/paths.ts +2 -0
- package/src/init/run-bun-install.ts +20 -0
- package/src/markdown/chunk.ts +299 -0
- package/src/markdown/index.ts +1 -0
- package/src/plugin/context.ts +40 -0
- package/src/plugin/define.ts +35 -0
- package/src/plugin/hooks.ts +204 -0
- package/src/plugin/index.ts +63 -0
- package/src/plugin/loader.ts +111 -0
- package/src/plugin/manager.ts +136 -0
- package/src/plugin/registry.ts +145 -0
- package/src/plugin/skills.ts +62 -0
- package/src/plugin/types.ts +172 -0
- package/src/portbroker/bind-with-forward.ts +102 -0
- package/src/portbroker/container-server.ts +305 -0
- package/src/portbroker/forward-result-bus.ts +36 -0
- package/src/portbroker/hostd-client.ts +443 -0
- package/src/portbroker/index.ts +33 -0
- package/src/portbroker/policy.ts +24 -0
- package/src/portbroker/proc-net-tcp.ts +72 -0
- package/src/portbroker/protocol.ts +39 -0
- package/src/reload/client.ts +59 -0
- package/src/reload/index.ts +3 -0
- package/src/reload/registry.ts +60 -0
- package/src/reload/types.ts +13 -0
- package/src/run/bundled-plugins.ts +24 -0
- package/src/run/channel-session-factory.ts +105 -0
- package/src/run/index.ts +432 -0
- package/src/run/plugin-runtime.ts +43 -0
- package/src/run/schema-with-plugins.ts +14 -0
- package/src/secrets/index.ts +13 -0
- package/src/secrets/migrate.ts +95 -0
- package/src/secrets/schema.ts +75 -0
- package/src/secrets/storage.ts +231 -0
- package/src/server/index.ts +436 -0
- package/src/sessions/index.ts +23 -0
- package/src/shared/index.ts +9 -0
- package/src/shared/local-time.ts +21 -0
- package/src/shared/protocol.ts +25 -0
- package/src/skills/typeclaw-channel-kakaotalk/SKILL.md +87 -0
- package/src/skills/typeclaw-channel-telegram-bot/SKILL.md +64 -0
- package/src/skills/typeclaw-config/SKILL.md +643 -0
- package/src/skills/typeclaw-cron/SKILL.md +159 -0
- package/src/skills/typeclaw-git/SKILL.md +89 -0
- package/src/skills/typeclaw-memory/SKILL.md +174 -0
- package/src/skills/typeclaw-monorepo/SKILL.md +175 -0
- package/src/skills/typeclaw-plugins/SKILL.md +594 -0
- package/src/skills/typeclaw-skills/SKILL.md +246 -0
- package/src/stream/broker.ts +161 -0
- package/src/stream/index.ts +16 -0
- package/src/stream/types.ts +69 -0
- package/src/tui/client.ts +45 -0
- package/src/tui/format.ts +317 -0
- package/src/tui/index.ts +225 -0
- package/src/tui/theme.ts +41 -0
- package/typeclaw.schema.json +826 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type { ChannelParticipant } from '@/agent/session-origin'
|
|
2
|
+
|
|
3
|
+
import { MEMBERSHIP_FRESHNESS_MS, type MembershipCount } from './membership'
|
|
4
|
+
import type { EngagementConfig } from './schema'
|
|
5
|
+
import type { InboundMessage } from './types'
|
|
6
|
+
|
|
7
|
+
export type EngagementDecision = 'engage' | 'observe'
|
|
8
|
+
|
|
9
|
+
export type StickyCredit = { authorId: string; expiresAt: number }
|
|
10
|
+
|
|
11
|
+
// Per-key sticky credit ledger. Each key (channel tuple) carries at most one
|
|
12
|
+
// active credit per author at a time (subsequent grants overwrite expiry).
|
|
13
|
+
export class StickyLedger {
|
|
14
|
+
private byKey = new Map<string, Map<string, number>>()
|
|
15
|
+
|
|
16
|
+
grant(key: string, authorId: string, expiresAt: number): void {
|
|
17
|
+
let inner = this.byKey.get(key)
|
|
18
|
+
if (!inner) {
|
|
19
|
+
inner = new Map()
|
|
20
|
+
this.byKey.set(key, inner)
|
|
21
|
+
}
|
|
22
|
+
inner.set(authorId, expiresAt)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
consume(key: string, authorId: string, now: number): boolean {
|
|
26
|
+
const inner = this.byKey.get(key)
|
|
27
|
+
if (!inner) return false
|
|
28
|
+
const expiresAt = inner.get(authorId)
|
|
29
|
+
if (expiresAt === undefined) return false
|
|
30
|
+
inner.delete(authorId)
|
|
31
|
+
if (inner.size === 0) this.byKey.delete(key)
|
|
32
|
+
return expiresAt > now
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
has(key: string, authorId: string, now: number): boolean {
|
|
36
|
+
const expiresAt = this.byKey.get(key)?.get(authorId)
|
|
37
|
+
return expiresAt !== undefined && expiresAt > now
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
clear(key: string): void {
|
|
41
|
+
this.byKey.delete(key)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type EngagementInput = {
|
|
46
|
+
message: InboundMessage
|
|
47
|
+
config: EngagementConfig
|
|
48
|
+
key: string
|
|
49
|
+
ledger: StickyLedger
|
|
50
|
+
now: number
|
|
51
|
+
// Router updates this cache with the current sender BEFORE calling here,
|
|
52
|
+
// so a fresh channel's first human message arrives with length 1. Peer
|
|
53
|
+
// bots DO enter the cache now (they were dropped at adapter level
|
|
54
|
+
// before), so the solo-human fallback below filters them out explicitly
|
|
55
|
+
// — otherwise a 1-human + N-bot channel would silently exit solo mode.
|
|
56
|
+
participants: readonly ChannelParticipant[]
|
|
57
|
+
membership: MembershipCount | null
|
|
58
|
+
// Names the agent answers to in plain text (no @mention syntax). Built
|
|
59
|
+
// by the router as `[basename(agentDir), ...config.alias]` and lowered
|
|
60
|
+
// once. Empty list means alias-based engagement is off — useful for
|
|
61
|
+
// tests and for agents that explicitly want strict-mention behavior.
|
|
62
|
+
// Match semantics: case-insensitive substring of inbound text. This is
|
|
63
|
+
// the operator contract documented in typeclaw-config; if a name is too
|
|
64
|
+
// generic ("bot", "ai") it WILL produce false matches and the operator
|
|
65
|
+
// owns curation.
|
|
66
|
+
selfAliases: readonly string[]
|
|
67
|
+
// True when the bot has previously sent into this exact thread (or
|
|
68
|
+
// channel — the suppressor only checks this when the message is a
|
|
69
|
+
// thread reply, but the field is general). Set by the router from
|
|
70
|
+
// `live.successfulChannelSends > 0` plus any bot-authored prefetched
|
|
71
|
+
// history in `contextBuffer`. Suppresses the `replyToOtherMessageId`
|
|
72
|
+
// gate below: once the bot is participating in a thread, subsequent
|
|
73
|
+
// replies are part of OUR conversation even when `parent_user_id` (the
|
|
74
|
+
// thread root author) is a human. Without this, a thread that started
|
|
75
|
+
// with a human @-mention drops every follow-up reply because Slack's
|
|
76
|
+
// `parent_user_id` always points at the (human) thread root, never the
|
|
77
|
+
// bot's intermediate replies — see incident in PR #58 follow-up.
|
|
78
|
+
botInThread: boolean
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
82
|
+
const { message, config, key, ledger, now, participants, selfAliases, botInThread } = input
|
|
83
|
+
|
|
84
|
+
if (config.trigger.includes('dm') && message.isDm) return 'engage'
|
|
85
|
+
if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
|
|
86
|
+
if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
|
|
87
|
+
|
|
88
|
+
if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now)) {
|
|
89
|
+
return 'engage'
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Plain-text name addressing: the user wrote our name (or an alias)
|
|
93
|
+
// somewhere in the message without using <@id> syntax. Engage at the
|
|
94
|
+
// same priority as an explicit mention — operators add aliases
|
|
95
|
+
// precisely because they expect the bot to respond when called by
|
|
96
|
+
// name. Suppression on `mentionsOthers` would defeat the point: the
|
|
97
|
+
// user can address two bots by name in one message ("봉봉아 펭펭아 둘
|
|
98
|
+
// 다 봐") and both should engage. Each bot only knows its own
|
|
99
|
+
// aliases, so cross-bot suppression isn't possible at this layer
|
|
100
|
+
// anyway — the router-side peer-name suppression in the solo-human
|
|
101
|
+
// fallback handles that case (follow-up).
|
|
102
|
+
if (matchesAnyAlias(message.text, selfAliases)) return 'engage'
|
|
103
|
+
|
|
104
|
+
// Solo-human fallback: the strict mention/reply/dm gate keeps the bot
|
|
105
|
+
// quiet in multi-human conversations, but in a 1-human channel that
|
|
106
|
+
// same gate makes the agent silent on messages plainly meant for it.
|
|
107
|
+
// The fallback engages on any human inbound when the channel has at
|
|
108
|
+
// most one human participant, and reverts to strict the moment a second
|
|
109
|
+
// human posts. Peer bots are tracked as participants for context but
|
|
110
|
+
// excluded from the count here, so a 1-human channel stays "solo" even
|
|
111
|
+
// when several bots also speak in it.
|
|
112
|
+
//
|
|
113
|
+
// Two suppressors override the fallback when the message is clearly
|
|
114
|
+
// addressed to someone else:
|
|
115
|
+
// 1. `mentionsOthers` — the message tags at least one other user and
|
|
116
|
+
// none of the mentions resolve to us.
|
|
117
|
+
// 2. `replyToOtherMessageId` — the message is a reply, but the parent
|
|
118
|
+
// was authored by someone other than us (Discord's threaded reply
|
|
119
|
+
// arrow is the canonical case).
|
|
120
|
+
// Both are populated by the adapter classifiers; either one flips the
|
|
121
|
+
// fallback off for that single message without changing channel state.
|
|
122
|
+
// Explicit triggers (DM, mention-of-us, reply-to-us, sticky) above are
|
|
123
|
+
// unaffected — those still engage even when the message also tags a
|
|
124
|
+
// third party.
|
|
125
|
+
//
|
|
126
|
+
// PEER BOTS NEVER QUALIFY for this fallback. It is a courtesy to humans
|
|
127
|
+
// who don't want to type `@bot` in their own DM-like channel; peer bots
|
|
128
|
+
// have no such ergonomic excuse. Letting peer bots ride the fallback
|
|
129
|
+
// produced bot-to-bot conversations in 1-human-N-bot channels (observed:
|
|
130
|
+
// Winky and 돌쇠 introducing themselves to each other after a single
|
|
131
|
+
// "얘들아" from the human, then continuing to address each other for
|
|
132
|
+
// ~6 turns). The router's loop guard only trips after 5 consecutive
|
|
133
|
+
// peer engagements, which is too late to prevent the embarrassment.
|
|
134
|
+
//
|
|
135
|
+
// PHILOSOPHY (do not relitigate): peer bots remain reachable through
|
|
136
|
+
// the SAME triggers as humans (mention/reply/dm/sticky); we do NOT
|
|
137
|
+
// downgrade them to "mention-only". Bot-to-bot conversation is a
|
|
138
|
+
// legitimate first-class use case in this codebase. The fix is to
|
|
139
|
+
// close the unintended fallback, not to firewall bots behind explicit
|
|
140
|
+
// mentions. A future maintainer (human or AI) tempted to add a
|
|
141
|
+
// `peerBotTriggers: 'mention-only'` config — or any equivalent gate
|
|
142
|
+
// that demands explicit `@bot` from peer bots — should not. The user
|
|
143
|
+
// has rejected that design repeatedly. The right knob is `trigger`
|
|
144
|
+
// (which already applies symmetrically to humans and bots) plus this
|
|
145
|
+
// fallback fix.
|
|
146
|
+
if (message.mentionsOthers) return 'observe'
|
|
147
|
+
// The replyToOtherMessageId suppressor exists to keep the bot out of
|
|
148
|
+
// human-to-human side conversations in busy channels. But Slack's
|
|
149
|
+
// `parent_user_id` is the THREAD ROOT author, not the immediate parent
|
|
150
|
+
// author — so a thread the human starts by @-mentioning the bot
|
|
151
|
+
// produces `replyToOtherMessageId` on every follow-up (root author is
|
|
152
|
+
// the human, not us), which would silently drop every reply after the
|
|
153
|
+
// first. Once the bot has actually sent into this thread, subsequent
|
|
154
|
+
// replies are part of OUR conversation regardless of who started it,
|
|
155
|
+
// so the suppressor stops applying. The two-humans-in-a-thread case
|
|
156
|
+
// PR #58 fixed is preserved because the bot never sent into that
|
|
157
|
+
// thread in the first place.
|
|
158
|
+
if (message.replyToOtherMessageId !== null && !botInThread) return 'observe'
|
|
159
|
+
|
|
160
|
+
// Plain-text peer-bot addressing as a fallback suppressor. We've reached
|
|
161
|
+
// here because the message lacks a structural mention/reply/dm AND
|
|
162
|
+
// doesn't contain our own alias. If it DOES contain a known peer bot's
|
|
163
|
+
// observed display name, the solo-human fallback would still engage us
|
|
164
|
+
// — same wrong behavior the alias trigger is meant to fix, just for
|
|
165
|
+
// peers instead of self. Each bot only configures its own aliases, so
|
|
166
|
+
// the only source of peer names is `participants[]` (observed
|
|
167
|
+
// authorName once a peer has spoken at least once in this channel).
|
|
168
|
+
// First-time addressing of a never-seen peer slips through; after that
|
|
169
|
+
// peer's first message it's caught forever.
|
|
170
|
+
if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
|
|
171
|
+
|
|
172
|
+
const persistedHumans = participants.filter((p) => p.isBot !== true).length
|
|
173
|
+
const effectiveHumans = resolveEffectiveHumans(persistedHumans, input.membership, now)
|
|
174
|
+
if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
|
|
175
|
+
|
|
176
|
+
return 'observe'
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function textTargetsAnyPeerBot(text: string, participants: readonly ChannelParticipant[]): boolean {
|
|
180
|
+
const haystack = text.toLocaleLowerCase()
|
|
181
|
+
for (const p of participants) {
|
|
182
|
+
if (p.isBot !== true) continue
|
|
183
|
+
if (p.authorName === '') continue
|
|
184
|
+
if (haystack.includes(p.authorName.toLocaleLowerCase())) return true
|
|
185
|
+
}
|
|
186
|
+
return false
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function resolveEffectiveHumans(
|
|
190
|
+
persistedHumans: number,
|
|
191
|
+
membership: MembershipCount | null,
|
|
192
|
+
now: number,
|
|
193
|
+
): number {
|
|
194
|
+
if (membership === null) return persistedHumans
|
|
195
|
+
// A fresh complete API read is the only signal that can see lurkers AND
|
|
196
|
+
// prune recent leavers. Letting persisted speakers win here would preserve
|
|
197
|
+
// the exact stale-authorship bug the membership lookup exists to fix.
|
|
198
|
+
const isFresh = now - membership.fetchedAt < MEMBERSHIP_FRESHNESS_MS
|
|
199
|
+
if (!membership.truncated && isFresh) return membership.humans
|
|
200
|
+
// Truncated and stale reads are useful quieting hints, not ground truth.
|
|
201
|
+
// Persisted speakers are bounded to the last 7 days, so `max()` avoids
|
|
202
|
+
// under-counting active humans while the platform count is approximate.
|
|
203
|
+
return Math.max(persistedHumans, membership.humans)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function grantStickyForReplyTargets(
|
|
207
|
+
ledger: StickyLedger,
|
|
208
|
+
key: string,
|
|
209
|
+
authorIds: readonly string[],
|
|
210
|
+
config: EngagementConfig,
|
|
211
|
+
now: number,
|
|
212
|
+
): void {
|
|
213
|
+
if (config.stickiness === 'off') return
|
|
214
|
+
const window = config.stickiness.perReply.window
|
|
215
|
+
for (const id of authorIds) {
|
|
216
|
+
ledger.grant(key, id, now + window)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export function matchesAnyAlias(text: string, lowercasedAliases: readonly string[]): boolean {
|
|
221
|
+
if (lowercasedAliases.length === 0) return false
|
|
222
|
+
const haystack = text.toLocaleLowerCase()
|
|
223
|
+
for (const alias of lowercasedAliases) {
|
|
224
|
+
if (haystack.includes(alias)) return true
|
|
225
|
+
}
|
|
226
|
+
return false
|
|
227
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export { createChannelManager, type ChannelManager, type ChannelManagerOptions } from './manager'
|
|
2
|
+
export {
|
|
3
|
+
createChannelRouter,
|
|
4
|
+
type ChannelRouter,
|
|
5
|
+
type CreateChannelRouterOptions,
|
|
6
|
+
type CreateSessionForChannel,
|
|
7
|
+
} from './router'
|
|
8
|
+
export { createChannelsReloadable } from './reloadable'
|
|
9
|
+
export {
|
|
10
|
+
channelsSchema,
|
|
11
|
+
isAllowed,
|
|
12
|
+
ADAPTER_IDS,
|
|
13
|
+
STICKY_DEFAULT_WINDOW_MS,
|
|
14
|
+
type AdapterId,
|
|
15
|
+
type AllowRule,
|
|
16
|
+
type ChannelAdapterConfig,
|
|
17
|
+
type ChannelsConfig,
|
|
18
|
+
type EngagementConfig,
|
|
19
|
+
} from './schema'
|
|
20
|
+
export type { ChannelKey, InboundMessage, OutboundCallback, OutboundMessage, SendResult } from './types'
|
|
21
|
+
export { channelKeyId } from './types'
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
|
|
6
|
+
import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
|
|
7
|
+
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
8
|
+
import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
|
|
9
|
+
import { createChannelRouter, type ChannelRouter, type CreateSessionForChannel } from './router'
|
|
10
|
+
import { ADAPTER_IDS, type AdapterId, type ChannelAdapterConfig, type ChannelsConfig } from './schema'
|
|
11
|
+
|
|
12
|
+
export type ChannelManagerLogger = {
|
|
13
|
+
info: (msg: string) => void
|
|
14
|
+
warn: (msg: string) => void
|
|
15
|
+
error: (msg: string) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const consoleLogger: ChannelManagerLogger = {
|
|
19
|
+
info: (m) => console.log(m),
|
|
20
|
+
warn: (m) => console.warn(m),
|
|
21
|
+
error: (m) => console.error(m),
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ChannelManagerOptions = {
|
|
25
|
+
agentDir: string
|
|
26
|
+
channelsConfigRef: () => ChannelsConfig
|
|
27
|
+
// Plain-text names the agent answers to in channel engagement (the
|
|
28
|
+
// `alias` field in `typeclaw.json`), forwarded to the router as
|
|
29
|
+
// `configuredAliases`. Read live on every inbound so an `applied`-class
|
|
30
|
+
// reload of `alias` takes effect without a container restart. Omitted
|
|
31
|
+
// means alias-based engagement is off — `basename(agentDir)` is still
|
|
32
|
+
// implicit. This MUST be wired up in production (`src/run/index.ts`)
|
|
33
|
+
// or the configured aliases are silently orphaned: parsed by the
|
|
34
|
+
// schema, never read by anyone. See `manager.test.ts` for the
|
|
35
|
+
// end-to-end engagement assertion that guards this wiring.
|
|
36
|
+
aliasesRef?: () => readonly string[]
|
|
37
|
+
logger?: ChannelManagerLogger
|
|
38
|
+
env?: NodeJS.ProcessEnv
|
|
39
|
+
// Production wiring passes a factory that builds sessions with the full
|
|
40
|
+
// runtime plumbing (channelRouter, stream, plugins, reloadRegistry). When
|
|
41
|
+
// omitted, the router falls back to a hollow factory that creates sessions
|
|
42
|
+
// without a channelRouter — the agent then has no `channel_send` tool and
|
|
43
|
+
// cannot reply, which is fine for tests but a bug in production. See
|
|
44
|
+
// src/run/index.ts where this is wired.
|
|
45
|
+
createSessionForChannel?: CreateSessionForChannel
|
|
46
|
+
// Test seams: let fake adapters replace the real adapter wiring per id.
|
|
47
|
+
createDiscordAdapter?: typeof createDiscordBotAdapter
|
|
48
|
+
createKakaotalkAdapter?: typeof createKakaotalkAdapter
|
|
49
|
+
createSlackAdapter?: typeof createSlackBotAdapter
|
|
50
|
+
createTelegramAdapter?: typeof createTelegramBotAdapter
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type ChannelManager = {
|
|
54
|
+
router: ChannelRouter
|
|
55
|
+
start: () => Promise<void>
|
|
56
|
+
stop: () => Promise<void>
|
|
57
|
+
reload: () => Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
type AnyAdapter = DiscordBotAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
|
|
61
|
+
|
|
62
|
+
// Credential signature is the comparison key for credential-rotation
|
|
63
|
+
// detection on reload. Discord and Telegram each use a single bot token;
|
|
64
|
+
// Slack needs both a bot token and an app-level token (Socket Mode);
|
|
65
|
+
// KakaoTalk authenticates via a credentials file under
|
|
66
|
+
// AGENT_MESSENGER_CONFIG_DIR (workspace/), so its signature is the file's
|
|
67
|
+
// content hash. The "credential" naming (vs "token") generalizes across the
|
|
68
|
+
// env-var-based adapters and KakaoTalk's file-based credential pathway.
|
|
69
|
+
type AdapterEntry = {
|
|
70
|
+
adapter: AnyAdapter
|
|
71
|
+
credentialSignature: string
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createChannelManager(options: ChannelManagerOptions): ChannelManager {
|
|
75
|
+
const logger = options.logger ?? consoleLogger
|
|
76
|
+
const env = options.env ?? process.env
|
|
77
|
+
const router = createChannelRouter({
|
|
78
|
+
agentDir: options.agentDir,
|
|
79
|
+
configForAdapter: (adapter) => options.channelsConfigRef()[adapter],
|
|
80
|
+
logger,
|
|
81
|
+
...(options.aliasesRef ? { configuredAliases: options.aliasesRef } : {}),
|
|
82
|
+
...(options.createSessionForChannel ? { createSessionForChannel: options.createSessionForChannel } : {}),
|
|
83
|
+
})
|
|
84
|
+
const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
|
|
85
|
+
const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
|
|
86
|
+
const createSlackAdapter = options.createSlackAdapter ?? createSlackBotAdapter
|
|
87
|
+
const createTelegramAdapter = options.createTelegramAdapter ?? createTelegramBotAdapter
|
|
88
|
+
|
|
89
|
+
const live = new Map<AdapterId, AdapterEntry>()
|
|
90
|
+
|
|
91
|
+
const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
|
|
92
|
+
if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir, env)
|
|
93
|
+
const requiredEnvs = TOKEN_ENV[name]
|
|
94
|
+
const parts: string[] = []
|
|
95
|
+
const missing: string[] = []
|
|
96
|
+
for (const key of requiredEnvs) {
|
|
97
|
+
const value = env[key]
|
|
98
|
+
if (value === undefined || value.trim() === '') missing.push(key)
|
|
99
|
+
else parts.push(`${key}=${value}`)
|
|
100
|
+
}
|
|
101
|
+
return { signature: parts.join('|'), missing }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const buildAdapter = (name: AdapterId, cfg: ChannelAdapterConfig): AnyAdapter | null => {
|
|
105
|
+
if (name === 'discord-bot') {
|
|
106
|
+
const token = env.DISCORD_BOT_TOKEN
|
|
107
|
+
if (token === undefined || token.trim() === '') return null
|
|
108
|
+
return createDiscordAdapter({
|
|
109
|
+
router,
|
|
110
|
+
configRef: () => options.channelsConfigRef()[name] ?? cfg,
|
|
111
|
+
token,
|
|
112
|
+
logger,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
if (name === 'slack-bot') {
|
|
116
|
+
const token = env.SLACK_BOT_TOKEN
|
|
117
|
+
const appToken = env.SLACK_APP_TOKEN
|
|
118
|
+
if (token === undefined || token.trim() === '') return null
|
|
119
|
+
if (appToken === undefined || appToken.trim() === '') return null
|
|
120
|
+
return createSlackAdapter({
|
|
121
|
+
router,
|
|
122
|
+
configRef: () => options.channelsConfigRef()[name] ?? cfg,
|
|
123
|
+
token,
|
|
124
|
+
appToken,
|
|
125
|
+
logger,
|
|
126
|
+
selfAliasesRef: () => router.getSelfAliases(),
|
|
127
|
+
})
|
|
128
|
+
}
|
|
129
|
+
if (name === 'kakaotalk') {
|
|
130
|
+
return createKakaotalk({
|
|
131
|
+
router,
|
|
132
|
+
configRef: () => options.channelsConfigRef()[name] ?? cfg,
|
|
133
|
+
logger,
|
|
134
|
+
selfAliasesRef: () => router.getSelfAliases(),
|
|
135
|
+
credentialsDir: resolveKakaoConfigDir(options.agentDir, env),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
if (name === 'telegram-bot') {
|
|
139
|
+
const token = env.TELEGRAM_BOT_TOKEN
|
|
140
|
+
if (token === undefined || token.trim() === '') return null
|
|
141
|
+
return createTelegramAdapter({
|
|
142
|
+
router,
|
|
143
|
+
configRef: () => options.channelsConfigRef()[name] ?? cfg,
|
|
144
|
+
token,
|
|
145
|
+
logger,
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
return null
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const startAdapter = async (name: AdapterId, cfg: ChannelAdapterConfig): Promise<boolean> => {
|
|
152
|
+
if (cfg.enabled === false) {
|
|
153
|
+
logger.info(`[channels] adapter "${name}" is disabled; skipping`)
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
const { signature, missing } = buildCredentialSignature(name)
|
|
157
|
+
if (missing.length > 0) {
|
|
158
|
+
logger.error(`[channels] adapter "${name}" missing credentials: ${missing.join(', ')}; skipping`)
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
const adapter = buildAdapter(name, cfg)
|
|
162
|
+
if (adapter === null) {
|
|
163
|
+
logger.error(`[channels] adapter "${name}" could not be constructed; skipping`)
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await adapter.start()
|
|
168
|
+
live.set(name, { adapter, credentialSignature: signature })
|
|
169
|
+
logger.info(`[channels] adapter "${name}" started`)
|
|
170
|
+
return true
|
|
171
|
+
} catch (err) {
|
|
172
|
+
logger.error(`[channels] adapter "${name}" failed to start: ${describe(err)}`)
|
|
173
|
+
return false
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const stopAdapter = async (name: AdapterId): Promise<void> => {
|
|
178
|
+
const entry = live.get(name)
|
|
179
|
+
if (!entry) return
|
|
180
|
+
live.delete(name)
|
|
181
|
+
try {
|
|
182
|
+
await entry.adapter.stop()
|
|
183
|
+
logger.info(`[channels] adapter "${name}" stopped`)
|
|
184
|
+
} catch (err) {
|
|
185
|
+
logger.error(`[channels] adapter "${name}" failed to stop: ${describe(err)}`)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
router,
|
|
191
|
+
|
|
192
|
+
async start(): Promise<void> {
|
|
193
|
+
const cfg = options.channelsConfigRef()
|
|
194
|
+
for (const name of ADAPTER_IDS) {
|
|
195
|
+
const adapterCfg = cfg[name]
|
|
196
|
+
if (adapterCfg !== undefined) await startAdapter(name, adapterCfg)
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async stop(): Promise<void> {
|
|
201
|
+
for (const name of Array.from(live.keys())) await stopAdapter(name)
|
|
202
|
+
await router.stop()
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async reload(): Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }> {
|
|
206
|
+
const cfg = options.channelsConfigRef()
|
|
207
|
+
const started: string[] = []
|
|
208
|
+
const stopped: string[] = []
|
|
209
|
+
const restartRequired: string[] = []
|
|
210
|
+
|
|
211
|
+
for (const name of ADAPTER_IDS) {
|
|
212
|
+
const desired = cfg[name]
|
|
213
|
+
const current = live.get(name)
|
|
214
|
+
if (desired === undefined || desired.enabled === false) {
|
|
215
|
+
if (current) {
|
|
216
|
+
await stopAdapter(name)
|
|
217
|
+
stopped.push(name)
|
|
218
|
+
}
|
|
219
|
+
} else if (!current) {
|
|
220
|
+
const ok = await startAdapter(name, desired)
|
|
221
|
+
if (ok) started.push(name)
|
|
222
|
+
} else {
|
|
223
|
+
const { signature, missing } = buildCredentialSignature(name)
|
|
224
|
+
if (missing.length > 0) {
|
|
225
|
+
// Required credentials disappeared (env vars removed from .env, or
|
|
226
|
+
// KakaoTalk credentials file deleted). Continuing to use the
|
|
227
|
+
// in-memory credentials would silently honor a credential the
|
|
228
|
+
// operator explicitly removed, so stop the adapter instead of
|
|
229
|
+
// waiting for a manual restart.
|
|
230
|
+
logger.warn(
|
|
231
|
+
`[channels] adapter "${name}" missing credentials after reload (${missing.join(', ')}); stopping`,
|
|
232
|
+
)
|
|
233
|
+
await stopAdapter(name)
|
|
234
|
+
stopped.push(name)
|
|
235
|
+
} else if (signature !== current.credentialSignature) {
|
|
236
|
+
const reason = name === 'kakaotalk' ? 'credential rotation' : 'token rotation'
|
|
237
|
+
restartRequired.push(`${name} (${reason})`)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return { started, stopped, restartRequired }
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Token-based adapters only. KakaoTalk's credentials live in a file under
|
|
248
|
+
// AGENT_MESSENGER_CONFIG_DIR (workspace/.agent-messenger/), not in env, so
|
|
249
|
+
// it goes through buildKakaotalkSignature instead.
|
|
250
|
+
const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk'>, readonly string[]> = {
|
|
251
|
+
'discord-bot': ['DISCORD_BOT_TOKEN'],
|
|
252
|
+
'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
|
|
253
|
+
'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const KAKAO_DEFAULT_SUBDIR = '.agent-messenger'
|
|
257
|
+
const KAKAO_CREDENTIALS_FILE = 'kakaotalk-credentials.json'
|
|
258
|
+
|
|
259
|
+
function resolveKakaoConfigDir(agentDir: string, env: NodeJS.ProcessEnv): string {
|
|
260
|
+
const override = env.AGENT_MESSENGER_CONFIG_DIR
|
|
261
|
+
if (override !== undefined && override.trim() !== '') return override
|
|
262
|
+
return join(agentDir, 'workspace', KAKAO_DEFAULT_SUBDIR)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function resolveKakaoCredentialsPath(agentDir: string, env: NodeJS.ProcessEnv): string {
|
|
266
|
+
return join(resolveKakaoConfigDir(agentDir, env), KAKAO_CREDENTIALS_FILE)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function buildKakaotalkSignature(agentDir: string, env: NodeJS.ProcessEnv): { signature: string; missing: string[] } {
|
|
270
|
+
const path = resolveKakaoCredentialsPath(agentDir, env)
|
|
271
|
+
if (!existsSync(path)) {
|
|
272
|
+
return { signature: '', missing: [`kakaotalk credentials file at ${path}`] }
|
|
273
|
+
}
|
|
274
|
+
try {
|
|
275
|
+
// Content hash, not mtime+size: KakaoTalk's credential file is small
|
|
276
|
+
// (a few hundred bytes of JSON) and is rewritten on every OAuth token
|
|
277
|
+
// refresh. Hashing avoids two failure modes mtime+size could miss:
|
|
278
|
+
// (a) a refresh that produces byte-identical content (rare but
|
|
279
|
+
// possible when nothing actually rotated) — we correctly skip;
|
|
280
|
+
// (b) a refresh that lands on the same mtime due to FS resolution
|
|
281
|
+
// (some host filesystems quantize to seconds).
|
|
282
|
+
const buf = readFileSync(path)
|
|
283
|
+
const digest = createHash('sha256').update(buf).digest('hex')
|
|
284
|
+
return { signature: `${path}@sha256:${digest}`, missing: [] }
|
|
285
|
+
} catch (err) {
|
|
286
|
+
return { signature: '', missing: [`kakaotalk credentials file at ${path} (${describe(err)})`] }
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function describe(err: unknown): string {
|
|
291
|
+
return err instanceof Error ? err.message : String(err)
|
|
292
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MEMBERSHIP_CACHE_PERMANENT_TTL_MS,
|
|
3
|
+
MEMBERSHIP_CACHE_TRANSIENT_TTL_MS,
|
|
4
|
+
MEMBERSHIP_CACHE_TTL_MS,
|
|
5
|
+
type MembershipCount,
|
|
6
|
+
type MembershipResolver,
|
|
7
|
+
type MembershipResolverFailure,
|
|
8
|
+
type MembershipResolverResult,
|
|
9
|
+
} from './membership'
|
|
10
|
+
import type { ChannelKey } from './types'
|
|
11
|
+
import { channelKeyId } from './types'
|
|
12
|
+
|
|
13
|
+
export type MembershipCacheRead =
|
|
14
|
+
| { kind: 'hit'; membership: MembershipCount | null }
|
|
15
|
+
| { kind: 'stale'; membership: MembershipCount }
|
|
16
|
+
| { kind: 'miss' }
|
|
17
|
+
|
|
18
|
+
type CacheEntry = {
|
|
19
|
+
result: MembershipResolverResult
|
|
20
|
+
expiresAt: number
|
|
21
|
+
servedStale: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type MembershipCacheLogger = {
|
|
25
|
+
warn: (msg: string) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type MembershipCacheOptions = {
|
|
29
|
+
resolver: MembershipResolver
|
|
30
|
+
now?: () => number
|
|
31
|
+
logger?: MembershipCacheLogger
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type MembershipCache = {
|
|
35
|
+
read: (key: ChannelKey) => MembershipCacheRead
|
|
36
|
+
get: (key: ChannelKey) => MembershipCount | null
|
|
37
|
+
warmUp: (key: ChannelKey) => Promise<MembershipCount | null>
|
|
38
|
+
invalidate: (key: ChannelKey) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createMembershipCache(options: MembershipCacheOptions): MembershipCache {
|
|
42
|
+
const now = options.now ?? Date.now
|
|
43
|
+
const entries = new Map<string, CacheEntry>()
|
|
44
|
+
const inFlight = new Map<string, Promise<MembershipCount | null>>()
|
|
45
|
+
|
|
46
|
+
const read = (key: ChannelKey): MembershipCacheRead => {
|
|
47
|
+
const entry = entries.get(channelKeyId(key))
|
|
48
|
+
if (entry === undefined) return { kind: 'miss' }
|
|
49
|
+
|
|
50
|
+
if (entry.expiresAt > now()) return { kind: 'hit', membership: toMembership(entry.result) }
|
|
51
|
+
if (isMembershipCount(entry.result) && !entry.servedStale) {
|
|
52
|
+
entry.servedStale = true
|
|
53
|
+
return { kind: 'stale', membership: entry.result }
|
|
54
|
+
}
|
|
55
|
+
return { kind: 'miss' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const warmUp = (key: ChannelKey): Promise<MembershipCount | null> => {
|
|
59
|
+
const keyId = channelKeyId(key)
|
|
60
|
+
const cached = read(key)
|
|
61
|
+
if (cached.kind === 'hit') return Promise.resolve(cached.membership)
|
|
62
|
+
if (cached.kind === 'stale') return Promise.resolve(cached.membership)
|
|
63
|
+
|
|
64
|
+
const existing = inFlight.get(keyId)
|
|
65
|
+
if (existing !== undefined) return existing
|
|
66
|
+
|
|
67
|
+
const promise = resolveAndStore(key, keyId).finally(() => {
|
|
68
|
+
inFlight.delete(keyId)
|
|
69
|
+
})
|
|
70
|
+
inFlight.set(keyId, promise)
|
|
71
|
+
return promise
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const resolveAndStore = async (key: ChannelKey, keyId: string): Promise<MembershipCount | null> => {
|
|
75
|
+
let result: MembershipResolverResult
|
|
76
|
+
try {
|
|
77
|
+
result = await options.resolver(key)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
options.logger?.warn(`[channels] membership resolver threw for ${keyId}: ${describe(err)}`)
|
|
80
|
+
result = { kind: 'transient' }
|
|
81
|
+
}
|
|
82
|
+
entries.set(keyId, { result, expiresAt: now() + ttlFor(result), servedStale: false })
|
|
83
|
+
return toMembership(result)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
read,
|
|
88
|
+
get: (key) => {
|
|
89
|
+
const cached = read(key)
|
|
90
|
+
return cached.kind === 'hit' ? cached.membership : null
|
|
91
|
+
},
|
|
92
|
+
warmUp,
|
|
93
|
+
invalidate: (key) => {
|
|
94
|
+
entries.delete(channelKeyId(key))
|
|
95
|
+
},
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function ttlFor(result: MembershipResolverResult): number {
|
|
100
|
+
if (isMembershipCount(result)) return MEMBERSHIP_CACHE_TTL_MS
|
|
101
|
+
return result.kind === 'permanent' ? MEMBERSHIP_CACHE_PERMANENT_TTL_MS : MEMBERSHIP_CACHE_TRANSIENT_TTL_MS
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toMembership(result: MembershipResolverResult): MembershipCount | null {
|
|
105
|
+
return isMembershipCount(result) ? result : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isMembershipCount(result: MembershipResolverResult): result is MembershipCount {
|
|
109
|
+
return 'humans' in result
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function describe(err: unknown): string {
|
|
113
|
+
return err instanceof Error ? err.message : String(err)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type { MembershipResolverFailure }
|