@dsbot-channel/core 0.0.0-stage → 0.7.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/assets/skill.md +203 -0
- package/cordis.patch.yml +6 -0
- package/dist/adapter-meta.d.ts +6 -0
- package/dist/adapter-meta.js +47 -0
- package/dist/adapter-plugin.d.ts +105 -0
- package/dist/adapter-plugin.js +56 -0
- package/dist/adapter-registry.d.ts +78 -0
- package/dist/adapter-registry.js +86 -0
- package/dist/adapter-status.d.ts +166 -0
- package/dist/adapter-status.js +97 -0
- package/dist/adapters/shared/status-route.d.ts +10 -0
- package/dist/adapters/shared/status-route.js +18 -0
- package/dist/bot-sessions.d.ts +50 -0
- package/dist/bot-sessions.js +229 -0
- package/dist/budget-config.d.ts +19 -0
- package/dist/budget-config.js +15 -0
- package/dist/budget-ledger.d.ts +101 -0
- package/dist/budget-ledger.js +234 -0
- package/dist/build-pipeline.d.ts +39 -0
- package/dist/build-pipeline.js +182 -0
- package/dist/capability-tools.d.ts +97 -0
- package/dist/capability-tools.js +381 -0
- package/dist/chain-closer.d.ts +13 -0
- package/dist/chain-closer.js +39 -0
- package/dist/chance.d.ts +8 -0
- package/dist/chance.js +23 -0
- package/dist/channel-stats.d.ts +68 -0
- package/dist/channel-stats.js +138 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +60 -0
- package/dist/context-buffer.d.ts +50 -0
- package/dist/context-buffer.js +84 -0
- package/dist/contract.d.ts +238 -0
- package/dist/contract.js +37 -0
- package/dist/control-tools.d.ts +49 -0
- package/dist/control-tools.js +74 -0
- package/dist/delivery.d.ts +35 -0
- package/dist/delivery.js +142 -0
- package/dist/event-build.d.ts +7 -0
- package/dist/event-build.js +103 -0
- package/dist/event-key.d.ts +8 -0
- package/dist/event-key.js +18 -0
- package/dist/event-keys.d.ts +26 -0
- package/dist/event-keys.js +80 -0
- package/dist/event-types.d.ts +121 -0
- package/dist/event-types.js +27 -0
- package/dist/identity.d.ts +42 -0
- package/dist/identity.js +178 -0
- package/dist/impression-block.d.ts +12 -0
- package/dist/impression-block.js +40 -0
- package/dist/impression-import.d.ts +29 -0
- package/dist/impression-import.js +111 -0
- package/dist/impression-tools.d.ts +14 -0
- package/dist/impression-tools.js +280 -0
- package/dist/impressions.d.ts +161 -0
- package/dist/impressions.js +811 -0
- package/dist/inbound-gate.d.ts +65 -0
- package/dist/inbound-gate.js +118 -0
- package/dist/inbound-guidance.d.ts +14 -0
- package/dist/inbound-guidance.js +34 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +30 -0
- package/dist/judge-providers.d.ts +36 -0
- package/dist/judge-providers.js +119 -0
- package/dist/judge-question.d.ts +24 -0
- package/dist/judge-question.js +91 -0
- package/dist/llm.d.ts +50 -0
- package/dist/llm.js +77 -0
- package/dist/match-spec.d.ts +26 -0
- package/dist/match-spec.js +113 -0
- package/dist/media-line.d.ts +15 -0
- package/dist/media-line.js +63 -0
- package/dist/own-recent.d.ts +16 -0
- package/dist/own-recent.js +73 -0
- package/dist/peer-sessions.d.ts +90 -0
- package/dist/peer-sessions.js +206 -0
- package/dist/pipeline-bindable.d.ts +8 -0
- package/dist/pipeline-bindable.js +9 -0
- package/dist/pipeline-config.d.ts +39 -0
- package/dist/pipeline-config.js +151 -0
- package/dist/pipeline-scopes.d.ts +66 -0
- package/dist/pipeline-scopes.js +142 -0
- package/dist/pipeline.d.ts +107 -0
- package/dist/pipeline.js +102 -0
- package/dist/plugin.d.ts +264 -0
- package/dist/plugin.js +1703 -0
- package/dist/policy-config.d.ts +17 -0
- package/dist/policy-config.js +57 -0
- package/dist/provenance.d.ts +19 -0
- package/dist/provenance.js +33 -0
- package/dist/push-tool.d.ts +81 -0
- package/dist/push-tool.js +148 -0
- package/dist/registry.d.ts +9 -0
- package/dist/registry.js +17 -0
- package/dist/reply-config.d.ts +73 -0
- package/dist/reply-config.js +238 -0
- package/dist/safe-url.d.ts +7 -0
- package/dist/safe-url.js +33 -0
- package/dist/session-pipeline.d.ts +27 -0
- package/dist/session-pipeline.js +96 -0
- package/dist/skill-asset.d.ts +22 -0
- package/dist/skill-asset.js +34 -0
- package/dist/system-one.d.ts +45 -0
- package/dist/system-one.js +104 -0
- package/dist/tool-errors.d.ts +5 -0
- package/dist/tool-errors.js +5 -0
- package/dist/turn-batch.d.ts +12 -0
- package/dist/turn-batch.js +17 -0
- package/dist/turn-billing.d.ts +16 -0
- package/dist/turn-billing.js +35 -0
- package/dist/turn-queue.d.ts +22 -0
- package/dist/turn-queue.js +49 -0
- package/dist/turn-stats.d.ts +79 -0
- package/dist/turn-stats.js +113 -0
- package/dist/turn-window.d.ts +27 -0
- package/dist/turn-window.js +55 -0
- package/dist/usage-view.d.ts +28 -0
- package/dist/usage-view.js +27 -0
- package/dist/warn.d.ts +4 -0
- package/dist/warn.js +27 -0
- package/package.json +53 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 zerodegress
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/assets/skill.md
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# dsbot-channel configuration
|
|
2
|
+
|
|
3
|
+
Configure the gateway: adapter links, delivery, policy, pipeline and one optional judge provider. The transports come from `@dsbot-channel/p2p`, `@dsbot-channel/onebot`, `@dsbot-channel/webhook` and `@dsbot-channel/mail`, each mounted as its own plugin. Every key is read at load; a key the plugin does not read fails the mount.
|
|
4
|
+
|
|
5
|
+
## Gateway
|
|
6
|
+
|
|
7
|
+
The gateway's own config holds the global defaults (`reply`, `turn`, `delivery`, `policy`) and the infrastructure (`pipeline.global`, `identity`, `impressions`, `skill`), listed under Behaviour blocks below. An adapter's `channel`, `botName`, `transport` and any behaviour block it overrides are written on that adapter's plugin entry; the compatibility shell `dsbot-channel` also reads the older `adapters` list, where an entry carries `kind` as well.
|
|
8
|
+
|
|
9
|
+
`channel` is the adapter's only name. `secret`, `accessToken` and judge `credential` are reference names in the host credentials service, not secrets. Paths are read as POSIX paths; Windows is not supported.
|
|
10
|
+
|
|
11
|
+
An adapter anchors its turns to exactly one of `botName` and `session`; both or neither fails the mount. `botName` follows that bot's current segment from the host roster at `$DSH_HOME/bot/state.json` (`~/.dsh/bot/state.json` when `DSH_HOME` is unset), read on every turn. The name is matched exactly first, by name and then by id, and ignoring case only when nothing matches exactly; two entries matching at the same step fail with `session_ambiguous`. The configured `botName` is the name peers address: an inbound `p2p` request carries it in `to` and must match it exactly, and an outbound turn sends it in `sender` as written. A missing roster keeps the last good session without a warning and fails the turn with `session_unresolved`, warning once, when there is none; an unreadable roster warns once and keeps the last good session, failing the turn when there is none. `session` pins one existing session, checked at mount and on every turn: `session_stale` when its bot has left it, `session_unknown` when no roster names it. One bot takes one anchor: pinning the session of a bot another adapter follows refuses the mount, and the turn fails with `session_anchor_conflict` when the roster was unreadable at mount. `cwd` and `agentPreset` are refused: sessions are never created. An adapter's internal identity is its `botName`, or its `channel` when it pins a `session`; a pinned adapter fixes its wire name at mount, the session owner's `name` when the roster is readable and its `channel` otherwise, and every turn checks `to` against that name and sends it in `sender`, unchanged when the bot moves to another segment. One adapter runs one turn at a time and selects `model` once per resolved session.
|
|
12
|
+
|
|
13
|
+
<!-- dsbot-channel: adapter skill sections -->
|
|
14
|
+
### Behaviour blocks
|
|
15
|
+
|
|
16
|
+
| Field | Default | Meaning |
|
|
17
|
+
| --- | --- | --- |
|
|
18
|
+
| `policy.requireMention` | `false` | group messages must name the bot or everyone; a transport that cannot tell mentions (`p2p`, `mail`) refuses it at mount |
|
|
19
|
+
| `policy.groupBots` | `whenMentioned` | `whenMentioned`, `silent` or `answer`; a `webhook` adapter starts at `answer` and answers its `transport.bots` logins unless the profile sets the key |
|
|
20
|
+
| `policy.relayLimit` | `3` | rounds one peer may hold in a row |
|
|
21
|
+
| `policy.relayResetMs` | `300000` | idle time that resets the count |
|
|
22
|
+
| `policy.budget.capacity` | off | points per window |
|
|
23
|
+
| `policy.budget.hardLimit` | `1` | ratio that refuses |
|
|
24
|
+
| `policy.budget.restrictAt` | off | ratio where a `budget` node may restrict; above 0 and below `hardLimit` |
|
|
25
|
+
| `policy.deny.words` / `regexes` | `[]` | refuse a message whose text matches |
|
|
26
|
+
| `policy.allow.peers` | `[]` | matches `source.peer`: a peer instance on p2p, or the group or user id of a OneBot event |
|
|
27
|
+
| `reply.default` | `auto` | `auto` or `tool` |
|
|
28
|
+
| `reply.byScope` | off | mode per scope: `c2c`, `group`, `p2p` |
|
|
29
|
+
| `reply.rules` | off | ordered rules; first match wins |
|
|
30
|
+
| `turn.coalesceMs` | `0` | merge window |
|
|
31
|
+
| `turn.maxBatch` | `10` | messages per batch |
|
|
32
|
+
| `turn.maxChars` | `8000` | batch budget |
|
|
33
|
+
| `turn.context.messages` | `0` | earlier messages put before the turn |
|
|
34
|
+
| `turn.context.chars` | `2000` | context budget |
|
|
35
|
+
| `delivery.split.byBlankLine` | `true` | split a reply at blank lines |
|
|
36
|
+
| `delivery.split.maxParts` | `4` | parts per reply, tail merged |
|
|
37
|
+
| `delivery.split.maxChars` | `0` | code points per part, off |
|
|
38
|
+
| `delivery.pacing.charsPerSecond` | `14` | reading speed for the wait |
|
|
39
|
+
| `delivery.pacing.minMs` | `700` | shortest wait between parts |
|
|
40
|
+
| `delivery.pacing.maxMs` | `5000` | longest wait between parts |
|
|
41
|
+
| `pipeline.maxTurnMs` | `120000` | one turn's budget |
|
|
42
|
+
| `pipeline.judgeTimeoutMs` | `8000` | one judge call, retries included |
|
|
43
|
+
| `pipeline.judgePoints` | `1` | points a verdict costs |
|
|
44
|
+
| `allowAtLeast` (judge node) | off | score that allows the message |
|
|
45
|
+
| `belowThreshold` (judge node) | `deny` | what a score below `allowAtLeast` does: `deny` or `pass`; fails the mount without `allowAtLeast` |
|
|
46
|
+
| `impressions.dir` | off | absolute notes root; the library is off without it |
|
|
47
|
+
| `impressions.import` | off | once: `{ path, as: { personId } }` or `{ path, as: { groupKey } }` |
|
|
48
|
+
| `turn.impressions.chars` | `400` | notes injected into a turn, `0` to `2000`; `0` injects none |
|
|
49
|
+
| `identity.people` / `identity.bots` | `[]` | nodes `{ id, accounts: [{ kind, instance, accountId }] }`; an account fills `author.personId` |
|
|
50
|
+
|
|
51
|
+
The impression library is off until a top-level `impressions` block names an absolute `dir`. It keeps short notes about the person a turn answers or about its group; `impression_put`, `impression_get` and `impression_forget` read and write them, `turn.impressions.chars` caps how much goes in front of a turn. `impressions.import` runs once per bot at mount from a file in the host memory format; `path` is absolute, `as.personId` must be a declared person, `as.groupKey` is `<channel>|<sessionKey>`; the channel may mount later, the import applies once it does, and a channel still missing at the assembly before the first inbound request records a warning that names it and runs no turn, it reads bullet lines only, and a second run adds nothing. Notes are injected for `c2c` and `group` turns only; on `p2p` the three tools answer `unsupported`. `impression_put` writes about the counterpart, which needs a `personId`, or with `about: "group"` about its group; `impression_get` takes `about: "group"` too; `impression_forget` takes `all: true` or `about: "group"`. `identity` declares `people` and `bots` nodes of `{ id, accounts: [{ kind, instance, accountId }] }`; ids and accounts appear once, `accounts` may not be empty, a built-in instance may mount later: its check runs in the assembly before the first inbound request, and an instance still missing then runs no turn and records a `ConfigError` warning that names it; the binding takes effect once the instance mounts, a configured p2p peer counts, a third-party kind is only warned about, and a resolved account fills `author.personId`.
|
|
52
|
+
|
|
53
|
+
## Judge provider
|
|
54
|
+
|
|
55
|
+
Mount one of two entries. Both provide `dsbot-channel/judge`, so a profile mounts one; a second mount fails at load with `one judge service per instance`. Mount the provider before the gateway: the gateway reads the service while it mounts.
|
|
56
|
+
|
|
57
|
+
A provider holds the connection; what to ask is written on the judge node, in the fields listed under `Pipeline nodes`.
|
|
58
|
+
|
|
59
|
+
A judge enters as one node of the pipeline, not as a gate: without `allowAtLeast` it only adds its score, and a chain that ends without an allow still refuses; with it, a score at or above the threshold allows the message, and one below refuses it, or with `belowThreshold: pass` adds its score and passes the chain on, recorded as `judge_below_threshold_pass`. A judge call costs its `judgePoints` whether it decides or only scores, and `belowThreshold` does not change that.
|
|
60
|
+
|
|
61
|
+
`system-one` asks the question its node names and reads a probability.
|
|
62
|
+
|
|
63
|
+
```yaml
|
|
64
|
+
- id: dsbot-channel/system-one
|
|
65
|
+
name: dsbot-channel/system-one
|
|
66
|
+
config:
|
|
67
|
+
credential: JUDGE_KEY
|
|
68
|
+
baseUrl: https://api.typesafe.ai/v1/systemone
|
|
69
|
+
model: jev-latest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`llm` asks an OpenAI-compatible chat completion for bare JSON `{"score","reason"}`.
|
|
73
|
+
|
|
74
|
+
```yaml
|
|
75
|
+
- id: dsbot-channel/llm
|
|
76
|
+
name: dsbot-channel/llm
|
|
77
|
+
config:
|
|
78
|
+
credential: JUDGE_KEY
|
|
79
|
+
baseUrl: https://openrouter.ai/api/v1
|
|
80
|
+
model: vendor/model
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| Field | Default | Meaning |
|
|
84
|
+
| --- | --- | --- |
|
|
85
|
+
| `credential` | required | reference name |
|
|
86
|
+
| `baseUrl` | required | absolute http(s) URL; `llm` posts to `{baseUrl}/chat/completions` |
|
|
87
|
+
| `model` | required | model id |
|
|
88
|
+
| `maxChars` | `2000` (`system-one`), `6000` (`llm`) | body is truncated to this; one node's `context` is capped at it too |
|
|
89
|
+
|
|
90
|
+
A key other than these four fails the mount and is named in the error. A judge service from another package that reports no `needsQuestion` or `maxChars` counts as `false` and `2000`: its nodes take a `context` of 2000 code points at most and no `question`.
|
|
91
|
+
|
|
92
|
+
The provider sets no timer of its own; `pipeline.judgeTimeoutMs` bounds the call. A `429` or `529` is retried once. Only the channel (`<kind>:<instance>`), the peer, the scope, the author id and kind, whether the bot was named, the message body and the node's `context` are sent — no history, no tools. `llm` accepts bare JSON only; a fenced block fails the verdict.
|
|
93
|
+
|
|
94
|
+
## Score provider
|
|
95
|
+
|
|
96
|
+
A `score` node needs a host service named `dsbot-channel/score`, which answers `score(event, context)` with a number, synchronously or as a promise. Mount it before the gateway: the gateway reads the service while it mounts.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import type { PipelineEvent, NodeContext } from 'dsbot-channel'
|
|
100
|
+
|
|
101
|
+
export function apply(ctx) {
|
|
102
|
+
ctx.provide('dsbot-channel/score')
|
|
103
|
+
ctx.set('dsbot-channel/score', {
|
|
104
|
+
score(event: PipelineEvent, context: NodeContext): number {
|
|
105
|
+
return context.score + 1
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The value is added to the running score; it may be negative and it decides nothing by itself. Later nodes see the running score in `context.score`, and a judge provider that reads it can fold it into its own verdict. The two built-in providers do not read it, so a score never enters their prompt. `allowAtLeast` compares the judge's own score, not the running total.
|
|
112
|
+
|
|
113
|
+
## How a message is decided
|
|
114
|
+
|
|
115
|
+
An inbound message is deduplicated by the adapter, then the policy keys in the table above decide whether it may become a turn, then the pipeline decides whether the turn is allowed. A refusal is recorded as `outcome: rejected` with the gate's code, with the detail in the log. `policy.deny` and `policy.allow` need a `deny` or `allow` node: a profile that declares no `pipeline` gets it added, one that declares a pipeline must add it. A judge is a node of the pipeline; its threshold, scoring and points are under `Judge provider`.
|
|
116
|
+
|
|
117
|
+
## Pipeline nodes
|
|
118
|
+
|
|
119
|
+
A node is `{ id, type }` plus the fields its type reads. The chain runs in order; the first `deny` or `allow` ends it, and a chain that reaches the end refuses. `id` names the node in `matched` and `reasons`.
|
|
120
|
+
|
|
121
|
+
| Node | Effect |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `deny` | refuses when `policy.deny` matches; no `policy.deny` rule fails the mount |
|
|
124
|
+
| `allow` | allows when `policy.allow` matches and stops later `score` and `judge`; no `policy.allow` peer fails the mount |
|
|
125
|
+
| `judge` | scores through the host judge service; `allowAtLeast` sets its threshold, `belowThreshold` decides what a lower score does, and it reads `question` and `context` |
|
|
126
|
+
| `score` | adds the value the host score service returns; a throw or a non-finite value refuses with `score_failed` |
|
|
127
|
+
| `budget` | reads the spend of the busiest window: a hard state refuses, and `mayRestrict` at `restrictAt` restricts |
|
|
128
|
+
| `prepare` | runs the host prepare hook, then passes; every adapter for the bot needs the hook |
|
|
129
|
+
| `chance` | fires with `probability`: a hit ends the chain as an `allow` or a `deny`, a miss passes |
|
|
130
|
+
| `when` | decides by a Matcher: a match returns its `outcome`, no match passes |
|
|
131
|
+
|
|
132
|
+
A `judge` node names what it asks and what it adds.
|
|
133
|
+
|
|
134
|
+
| Field | Default | Meaning |
|
|
135
|
+
| --- | --- | --- |
|
|
136
|
+
| `question` | required | what this node asks; `system-one` needs one, an `llm` node may not carry it |
|
|
137
|
+
| `question.type` | required | `noul`, `score` or `choice` |
|
|
138
|
+
| `question.instructions` | required | one question or statement |
|
|
139
|
+
| `question.criteria` | required | `noul`: `{ true, false }`; `score`: 2 to 10 ordered levels, low to high |
|
|
140
|
+
| `question.options` | required | for `choice`: at least two options |
|
|
141
|
+
| `question.target` | required | for `choice`: one of `question.options`; its probability becomes the score |
|
|
142
|
+
| `question.min` / `question.max` | off | `score` only: convert a raw score; equal values fail the verdict |
|
|
143
|
+
| `context` | off | static material for this node: a string or a mapping of name to string |
|
|
144
|
+
| `probability` | required | `chance` only: 0 to 1 |
|
|
145
|
+
| `outcome` | required | `when`: `allow`, `deny` or `pass`; `chance`: `allow` or `deny` |
|
|
146
|
+
| `test` | required | `when` only: the Matcher it reads |
|
|
147
|
+
| `when` | off | `chance` only: the Matcher it rolls behind |
|
|
148
|
+
| `restrictSkip` | off | `budget` only: what a restrict skips; required with `mayRestrict`. `scoring` skips `score` and `judge`; `judge` skips only `judge` |
|
|
149
|
+
| `mayRestrict` | off | `budget` only: turn a ratio at `policy.budget.restrictAt` into a restrict |
|
|
150
|
+
|
|
151
|
+
A `when` node decides by a Matcher: a match returns its `outcome`, no match passes. The same Matcher guards a `chance` node; a guard that does not hold passes without rolling, without a `counters.chance` entry and without a `chance_miss` reason.
|
|
152
|
+
|
|
153
|
+
| Matcher key | Values | Meaning |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| `scope` | `c2c`, `group`, `p2p` | the conversation kind |
|
|
156
|
+
| `peer` | a string, or `{ in: [...] }` / `{ notIn: [...] }` | the peer the event names; when the event names no peer, `in` does not match and `notIn` does |
|
|
157
|
+
| `mentioned` | `true` / `false` | this bot or everyone was named |
|
|
158
|
+
| `replyToMe` | `true` / `false` | the message quotes something this bot sent on the same channel |
|
|
159
|
+
| `ownRecent` | `true` / `false` | this bot has spoken here, the conversation has been quiet for less than ten minutes, and fewer than ten messages have arrived since |
|
|
160
|
+
| `event` | a string or a list of strings | the transport class, the same name `events.turn` takes, e.g. `action.ci`; absent when the adapter names no class, and a rule naming `event` then never matches |
|
|
161
|
+
| `action` | a string or a list of strings | the transport sub-action, e.g. `completed`; an event that names none never matches |
|
|
162
|
+
|
|
163
|
+
At least one key is required and every key present must hold. A successful send by the bot resets `ownRecent` and a failed send does not; it expires after ten minutes without a message in the conversation, and a message in the conversation refreshes it. `replyToMe` is reported by the adapter, false when the transport carries no parent message id; both are false for non-message events. `event` and `action` compare the transport class and sub-action; a list matches any of its entries, and an event that carries no sub-action never matches `action`. An event whose adapter names no class carries no `event`, so a rule naming `event` does not match it. A matcher with no key, an unknown key, both `in` and `notIn`, an empty list, a non-string class or a non-boolean flag fails the mount. A match records `when_allow` or `when_deny` under `reasons`; `pass` and a miss record nothing.
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
config:
|
|
167
|
+
pipeline:
|
|
168
|
+
global:
|
|
169
|
+
- { id: named, type: when, test: { mentioned: true }, outcome: allow }
|
|
170
|
+
- { id: roll, type: chance, when: { ownRecent: false }, probability: 0.99, outcome: deny }
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
A key other than these in `question` fails the mount and names the node and the field. One `context` may total `maxChars` code points, mapping names included; an empty name or value, another type or an over-long one fails the mount. `context` carries what the profile knows, not what happened: the channel, the peer, the scope, the author id and kind, whether the bot was named and the message body are sent already, and history, sessions and tools are never read. `llm` renders it after the message facts; `system-one` sends it as `state.context`.
|
|
174
|
+
|
|
175
|
+
`question`, `context`, `probability`, `outcome`, `test`, `when`, `restrictSkip` and `mayRestrict` may be written in `pipeline.global` and in an adapter's `insert` and `override`. A session layer's `insert` takes only `id`, `type`, `after` and `before`, so it carries none of them; a session's `override` may set them.
|
|
176
|
+
|
|
177
|
+
A `chance` node takes its bucket from a fixed hash of the event key and the node id: the same message replayed takes the same outcome, and two chance nodes on one message draw independently. It records `chance_allow`, `chance_deny` or `chance_miss` with `p=` under `reasons`, and the adapter that carried the message counts it under `counters.chance[nodeId]` as `{ hit, miss }`; the key appears only after the node has run.
|
|
178
|
+
|
|
179
|
+
A `score` node adds what `dsbot-channel/score` returns. A profile that declares one without that service fails the mount; a call that throws or returns anything but a finite number refuses the turn with `score_failed` and leaves the running score untouched. The running score is in the result; it decides nothing on its own, and `allowAtLeast`, `question` and `context` stay on `judge`.
|
|
180
|
+
|
|
181
|
+
A `budget` node reads the spend of the busiest window: the ratio is the largest of the global, bot and session usage divided by `policy.budget.capacity`. `hardLimit` refuses; with `mayRestrict`, a ratio at or above `policy.budget.restrictAt` returns a restrict, which skips what `restrictSkip` names and lets the chain continue. A restrict is not an allow: a chain that ends after one still refuses, with `pipeline-restrict` in the gate. `restrictSkip` without `mayRestrict`, `mayRestrict` without `restrictAt` or without a non-empty `restrictSkip`, declares a field that would do nothing and fails the mount.
|
|
182
|
+
|
|
183
|
+
An adapter changes the chain it starts from with `inherit`, `remove`, `insert` and `override`: `inherit` names the layer (an adapter takes `global`), `remove` drops nodes by id, `insert` adds a node after or before a named node (one of the two, no reused id), `override` replaces fields by id and only those the type reads; an unknown id fails the mount.
|
|
184
|
+
|
|
185
|
+
```yaml
|
|
186
|
+
config:
|
|
187
|
+
policy:
|
|
188
|
+
deny: { words: [spam] }
|
|
189
|
+
pipeline:
|
|
190
|
+
global:
|
|
191
|
+
- { id: guard, type: deny }
|
|
192
|
+
- { id: shadow, type: deny }
|
|
193
|
+
adapters:
|
|
194
|
+
- kind: p2p
|
|
195
|
+
channel: p2p:peer-a
|
|
196
|
+
botName: self-bot
|
|
197
|
+
transport: { listen: { host: 127.0.0.1, port: 7123 }, peers: { peer-b: PEER_B_REFERENCE } }
|
|
198
|
+
pipeline:
|
|
199
|
+
remove: [shadow]
|
|
200
|
+
insert: [{ id: extra, type: deny, after: guard }]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
`override` replaces fields by id; a judge node uses it for `allowAtLeast`, `belowThreshold`, `question` and `context`, and needs one provider mounted. A chain with no `allow` node and no judge threshold refuses every message.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Longest serialized adapter payload an event carries, so one transport cannot bloat every event. */
|
|
2
|
+
export declare const ADAPTER_META_MAX_BYTES: number;
|
|
3
|
+
/** How deep an adapter payload may nest. */
|
|
4
|
+
export declare const ADAPTER_META_MAX_DEPTH = 8;
|
|
5
|
+
/** Copies the payload core only carries: json-safe, bounded, and detached from the caller. */
|
|
6
|
+
export declare function adapterMetaOf(input: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** Longest serialized adapter payload an event carries, so one transport cannot bloat every event. */
|
|
2
|
+
export const ADAPTER_META_MAX_BYTES = 8 * 1024;
|
|
3
|
+
/** How deep an adapter payload may nest. */
|
|
4
|
+
export const ADAPTER_META_MAX_DEPTH = 8;
|
|
5
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
6
|
+
function refuse(where, reason) {
|
|
7
|
+
throw new Error(`meta.adapter${where}: ${reason}`);
|
|
8
|
+
}
|
|
9
|
+
function checkValue(value, where, depth) {
|
|
10
|
+
if (depth > ADAPTER_META_MAX_DEPTH)
|
|
11
|
+
refuse(where, `nests deeper than ${ADAPTER_META_MAX_DEPTH}`);
|
|
12
|
+
if (value === null)
|
|
13
|
+
return;
|
|
14
|
+
switch (typeof value) {
|
|
15
|
+
case 'string':
|
|
16
|
+
case 'boolean':
|
|
17
|
+
return;
|
|
18
|
+
case 'number':
|
|
19
|
+
if (!Number.isFinite(value))
|
|
20
|
+
refuse(where, 'numbers must be finite');
|
|
21
|
+
return;
|
|
22
|
+
case 'object':
|
|
23
|
+
break;
|
|
24
|
+
default:
|
|
25
|
+
refuse(where, `${typeof value} is not a json value`);
|
|
26
|
+
}
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
value.forEach((item, index) => checkValue(item, `${where}[${index}]`, depth + 1));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
for (const [key, item] of Object.entries(value)) {
|
|
32
|
+
if (FORBIDDEN_KEYS.has(key))
|
|
33
|
+
refuse(`${where}.${key}`, 'a key that can reach a prototype is refused');
|
|
34
|
+
checkValue(item, `${where}.${key}`, depth + 1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Copies the payload core only carries: json-safe, bounded, and detached from the caller. */
|
|
38
|
+
export function adapterMetaOf(input) {
|
|
39
|
+
if (input === undefined)
|
|
40
|
+
return {};
|
|
41
|
+
checkValue(input, '', 0);
|
|
42
|
+
const text = JSON.stringify(input);
|
|
43
|
+
if (Buffer.byteLength(text, 'utf8') > ADAPTER_META_MAX_BYTES) {
|
|
44
|
+
refuse('', `serializes to more than ${ADAPTER_META_MAX_BYTES} bytes`);
|
|
45
|
+
}
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
3
|
+
import type { AdapterFactory, AdapterMount } from './adapter-registry.js';
|
|
4
|
+
/** The fields an adapter plugin's own `config` carries: everything `AdapterConfig` has except `kind`. */
|
|
5
|
+
export declare const AdapterPluginConfig: Schema<Schemastery.ObjectS<NoInfer<{
|
|
6
|
+
channel: Schema<string, string, "plain">;
|
|
7
|
+
botName: Schema<string, string, "plain">;
|
|
8
|
+
anchor: Schema<string, string, "plain">;
|
|
9
|
+
session: Schema<string, string, "plain">;
|
|
10
|
+
model: Schema<string, string, "plain">;
|
|
11
|
+
reply: Schema<any, any, "plain">;
|
|
12
|
+
turn: Schema<any, any, "plain">;
|
|
13
|
+
delivery: Schema<any, any, "plain">;
|
|
14
|
+
policy: Schema<any, any, "plain">;
|
|
15
|
+
pipeline: Schema<any, any, "plain">;
|
|
16
|
+
events: Schema<any, any, "plain">;
|
|
17
|
+
status: Schema<boolean, boolean, "plain">;
|
|
18
|
+
transport: Schema<any, any, "plain">;
|
|
19
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
20
|
+
channel: Schema<string, string, "plain">;
|
|
21
|
+
botName: Schema<string, string, "plain">;
|
|
22
|
+
anchor: Schema<string, string, "plain">;
|
|
23
|
+
session: Schema<string, string, "plain">;
|
|
24
|
+
model: Schema<string, string, "plain">;
|
|
25
|
+
reply: Schema<any, any, "plain">;
|
|
26
|
+
turn: Schema<any, any, "plain">;
|
|
27
|
+
delivery: Schema<any, any, "plain">;
|
|
28
|
+
policy: Schema<any, any, "plain">;
|
|
29
|
+
pipeline: Schema<any, any, "plain">;
|
|
30
|
+
events: Schema<any, any, "plain">;
|
|
31
|
+
status: Schema<boolean, boolean, "plain">;
|
|
32
|
+
transport: Schema<any, any, "plain">;
|
|
33
|
+
}>>, "plain">;
|
|
34
|
+
export interface AdapterPluginSpec {
|
|
35
|
+
name: string;
|
|
36
|
+
kind: string;
|
|
37
|
+
factory: AdapterFactory;
|
|
38
|
+
check(mount: AdapterMount): unknown;
|
|
39
|
+
}
|
|
40
|
+
export declare function makeAdapterPlugin(spec: AdapterPluginSpec): {
|
|
41
|
+
Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
42
|
+
channel: Schema<string, string, "plain">;
|
|
43
|
+
botName: Schema<string, string, "plain">;
|
|
44
|
+
anchor: Schema<string, string, "plain">;
|
|
45
|
+
session: Schema<string, string, "plain">;
|
|
46
|
+
model: Schema<string, string, "plain">;
|
|
47
|
+
reply: Schema<any, any, "plain">;
|
|
48
|
+
turn: Schema<any, any, "plain">;
|
|
49
|
+
delivery: Schema<any, any, "plain">;
|
|
50
|
+
policy: Schema<any, any, "plain">;
|
|
51
|
+
pipeline: Schema<any, any, "plain">;
|
|
52
|
+
events: Schema<any, any, "plain">;
|
|
53
|
+
status: Schema<boolean, boolean, "plain">;
|
|
54
|
+
transport: Schema<any, any, "plain">;
|
|
55
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
56
|
+
channel: Schema<string, string, "plain">;
|
|
57
|
+
botName: Schema<string, string, "plain">;
|
|
58
|
+
anchor: Schema<string, string, "plain">;
|
|
59
|
+
session: Schema<string, string, "plain">;
|
|
60
|
+
model: Schema<string, string, "plain">;
|
|
61
|
+
reply: Schema<any, any, "plain">;
|
|
62
|
+
turn: Schema<any, any, "plain">;
|
|
63
|
+
delivery: Schema<any, any, "plain">;
|
|
64
|
+
policy: Schema<any, any, "plain">;
|
|
65
|
+
pipeline: Schema<any, any, "plain">;
|
|
66
|
+
events: Schema<any, any, "plain">;
|
|
67
|
+
status: Schema<boolean, boolean, "plain">;
|
|
68
|
+
transport: Schema<any, any, "plain">;
|
|
69
|
+
}>>, "plain">;
|
|
70
|
+
apply: (ctx: Context, config?: Record<string, unknown>) => void;
|
|
71
|
+
plugin: {
|
|
72
|
+
name: string;
|
|
73
|
+
inject: string[];
|
|
74
|
+
Config: Schema<Schemastery.ObjectS<NoInfer<{
|
|
75
|
+
channel: Schema<string, string, "plain">;
|
|
76
|
+
botName: Schema<string, string, "plain">;
|
|
77
|
+
anchor: Schema<string, string, "plain">;
|
|
78
|
+
session: Schema<string, string, "plain">;
|
|
79
|
+
model: Schema<string, string, "plain">;
|
|
80
|
+
reply: Schema<any, any, "plain">;
|
|
81
|
+
turn: Schema<any, any, "plain">;
|
|
82
|
+
delivery: Schema<any, any, "plain">;
|
|
83
|
+
policy: Schema<any, any, "plain">;
|
|
84
|
+
pipeline: Schema<any, any, "plain">;
|
|
85
|
+
events: Schema<any, any, "plain">;
|
|
86
|
+
status: Schema<boolean, boolean, "plain">;
|
|
87
|
+
transport: Schema<any, any, "plain">;
|
|
88
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
89
|
+
channel: Schema<string, string, "plain">;
|
|
90
|
+
botName: Schema<string, string, "plain">;
|
|
91
|
+
anchor: Schema<string, string, "plain">;
|
|
92
|
+
session: Schema<string, string, "plain">;
|
|
93
|
+
model: Schema<string, string, "plain">;
|
|
94
|
+
reply: Schema<any, any, "plain">;
|
|
95
|
+
turn: Schema<any, any, "plain">;
|
|
96
|
+
delivery: Schema<any, any, "plain">;
|
|
97
|
+
policy: Schema<any, any, "plain">;
|
|
98
|
+
pipeline: Schema<any, any, "plain">;
|
|
99
|
+
events: Schema<any, any, "plain">;
|
|
100
|
+
status: Schema<boolean, boolean, "plain">;
|
|
101
|
+
transport: Schema<any, any, "plain">;
|
|
102
|
+
}>>, "plain">;
|
|
103
|
+
apply: (ctx: Context, config?: Record<string, unknown>) => void;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import Schema from '@deepseek-ai/schemastery';
|
|
2
|
+
import { MOUNT_SERVICE, REGISTRY_SERVICE } from './adapter-registry.js';
|
|
3
|
+
import { ConfigError } from './config.js';
|
|
4
|
+
import { createWarn } from './warn.js';
|
|
5
|
+
/** The fields an adapter plugin's own `config` carries: everything `AdapterConfig` has except `kind`. */
|
|
6
|
+
export const AdapterPluginConfig = Schema.object({
|
|
7
|
+
channel: Schema.string(),
|
|
8
|
+
botName: Schema.string(),
|
|
9
|
+
anchor: Schema.string(),
|
|
10
|
+
session: Schema.string(),
|
|
11
|
+
model: Schema.string(),
|
|
12
|
+
reply: Schema.any(),
|
|
13
|
+
turn: Schema.any(),
|
|
14
|
+
delivery: Schema.any(),
|
|
15
|
+
policy: Schema.any(),
|
|
16
|
+
pipeline: Schema.any(),
|
|
17
|
+
events: Schema.any(),
|
|
18
|
+
status: Schema.boolean(),
|
|
19
|
+
transport: Schema.any(),
|
|
20
|
+
});
|
|
21
|
+
export function makeAdapterPlugin(spec) {
|
|
22
|
+
const apply = (ctx, config) => {
|
|
23
|
+
const registry = ctx.get(REGISTRY_SERVICE);
|
|
24
|
+
if (registry === undefined) {
|
|
25
|
+
throw new ConfigError(`${spec.name} needs the registry from @dsbot-channel/core; mount its registry plugin first`, []);
|
|
26
|
+
}
|
|
27
|
+
if (!registry.has(spec.kind))
|
|
28
|
+
registry.builtin(spec.kind, spec.factory);
|
|
29
|
+
const channel = config?.channel;
|
|
30
|
+
if (typeof channel !== 'string' || channel.length === 0) {
|
|
31
|
+
createWarn(ctx)(`${spec.name} mounted without a channel: no adapter instance; write it under this plugin's config`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const botName = config?.botName;
|
|
35
|
+
if (typeof botName !== 'string' || botName.length === 0) {
|
|
36
|
+
throw new ConfigError(`${spec.name}: config.botName must be a non-empty string`, ['botName']);
|
|
37
|
+
}
|
|
38
|
+
const transport = (config?.transport ?? {});
|
|
39
|
+
spec.check({ kind: spec.kind, channel, botName, transport });
|
|
40
|
+
const instance = {
|
|
41
|
+
...config,
|
|
42
|
+
kind: spec.kind,
|
|
43
|
+
channel,
|
|
44
|
+
botName,
|
|
45
|
+
transport,
|
|
46
|
+
source: `${spec.name} plugin`,
|
|
47
|
+
};
|
|
48
|
+
const mount = ctx.get(MOUNT_SERVICE);
|
|
49
|
+
if (mount === undefined)
|
|
50
|
+
registry.addInstance(instance);
|
|
51
|
+
else
|
|
52
|
+
mount.add(instance);
|
|
53
|
+
};
|
|
54
|
+
const plugin = { name: spec.name, inject: [REGISTRY_SERVICE], Config: AdapterPluginConfig, apply };
|
|
55
|
+
return { Config: AdapterPluginConfig, apply, plugin };
|
|
56
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { ChannelAdapter } from './contract.js';
|
|
3
|
+
/** What core knows about an adapter before it asks the transport to validate itself. */
|
|
4
|
+
export interface AdapterMount {
|
|
5
|
+
kind: string;
|
|
6
|
+
/** The one name this adapter answers to, on the wire and in the profile. */
|
|
7
|
+
channel: string;
|
|
8
|
+
botName: string;
|
|
9
|
+
/** The name peers address this Bot by; a pinned adapter may differ from its identity. */
|
|
10
|
+
wireBotName?: string | undefined;
|
|
11
|
+
/** The transport block the profile declared; only the adapter knows its shape. */
|
|
12
|
+
transport: Record<string, unknown>;
|
|
13
|
+
/** Whether this adapter answers its own status route; the transport decides what that means. */
|
|
14
|
+
status?: boolean | undefined;
|
|
15
|
+
}
|
|
16
|
+
export interface AdapterOptions {
|
|
17
|
+
warn(message: string): void;
|
|
18
|
+
}
|
|
19
|
+
export type AdapterFactory = (mount: AdapterMount, options: AdapterOptions) => ChannelAdapter;
|
|
20
|
+
/** Kinds the package ships and owns; a profile names them directly. */
|
|
21
|
+
export declare const BUILTIN_KINDS: readonly ["p2p", "onebot", "mail", "webhook"];
|
|
22
|
+
/** Longest kind an adapter may declare. */
|
|
23
|
+
export declare const ADAPTER_KIND_MAX = 32;
|
|
24
|
+
/** A third-party transport keeps to its own namespace, so it cannot pose as a built-in. */
|
|
25
|
+
export declare const THIRD_PARTY_KIND: RegExp;
|
|
26
|
+
/** The service one context's registry lives under; a second copy of this package sees another. */
|
|
27
|
+
export declare const REGISTRY_SERVICE = "dsbot-channel.registry";
|
|
28
|
+
/** Refuses a kind a profile declared that cannot identify a transport. */
|
|
29
|
+
export declare function checkAdapterKind(kind: string): void;
|
|
30
|
+
/** The service an adapter plugin hands its own configured instance to; the gateway mounts or queues it. */
|
|
31
|
+
export declare const MOUNT_SERVICE = "dsbot-channel.mount";
|
|
32
|
+
/** One adapter a plugin configured for itself: everything `AdapterConfig` carries except `kind` (the plugin fills it). */
|
|
33
|
+
export interface RegisteredAdapter {
|
|
34
|
+
kind: string;
|
|
35
|
+
/** Who declared this instance, so a duplicate can name both sides. */
|
|
36
|
+
source?: string | undefined;
|
|
37
|
+
channel: string;
|
|
38
|
+
botName?: string | undefined;
|
|
39
|
+
anchor?: 'bot' | 'session' | undefined;
|
|
40
|
+
session?: string | undefined;
|
|
41
|
+
model?: string | undefined;
|
|
42
|
+
reply?: unknown;
|
|
43
|
+
turn?: unknown;
|
|
44
|
+
delivery?: unknown;
|
|
45
|
+
policy?: unknown;
|
|
46
|
+
pipeline?: unknown;
|
|
47
|
+
events?: unknown;
|
|
48
|
+
status?: boolean | undefined;
|
|
49
|
+
transport?: Record<string, unknown> | undefined;
|
|
50
|
+
}
|
|
51
|
+
/** One context's transports: a third-party plugin and the gateway share this instance, no module state. */
|
|
52
|
+
export declare class AdapterRegistry {
|
|
53
|
+
#private;
|
|
54
|
+
/** Queues one configured instance; the gateway drains it at start. */
|
|
55
|
+
addInstance(instance: RegisteredAdapter): void;
|
|
56
|
+
/** Hands the gateway every queued instance. */
|
|
57
|
+
takeInstances(): RegisteredAdapter[];
|
|
58
|
+
/** Lets a third-party transport offer itself; its kind must stay in the `x-` namespace. One call per kind. */
|
|
59
|
+
register(kind: string, factory: AdapterFactory): void;
|
|
60
|
+
/** Registers a kind the package ships; only the gateway calls it, with its own copies. */
|
|
61
|
+
builtin(kind: string, factory: AdapterFactory): void;
|
|
62
|
+
has(kind: string): boolean;
|
|
63
|
+
/** The kinds registered in this context, for a message a user can act on. */
|
|
64
|
+
known(): string[];
|
|
65
|
+
/** The factory for a declared kind, or a refusal naming what is available and where the plugin goes. */
|
|
66
|
+
factory(kind: string): AdapterFactory;
|
|
67
|
+
private add;
|
|
68
|
+
}
|
|
69
|
+
export interface AdapterPluginOptions {
|
|
70
|
+
kind: string;
|
|
71
|
+
create: AdapterFactory;
|
|
72
|
+
}
|
|
73
|
+
/** The only supported way for a third-party package to offer a transport. */
|
|
74
|
+
export declare function defineAdapterPlugin(options: AdapterPluginOptions): {
|
|
75
|
+
name: string;
|
|
76
|
+
inject: readonly string[];
|
|
77
|
+
apply(ctx: Context): void;
|
|
78
|
+
};
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { ConfigError } from './config.js';
|
|
2
|
+
/** Kinds the package ships and owns; a profile names them directly. */
|
|
3
|
+
export const BUILTIN_KINDS = ['p2p', 'onebot', 'mail', 'webhook'];
|
|
4
|
+
/** Longest kind an adapter may declare. */
|
|
5
|
+
export const ADAPTER_KIND_MAX = 32;
|
|
6
|
+
/** A third-party transport keeps to its own namespace, so it cannot pose as a built-in. */
|
|
7
|
+
export const THIRD_PARTY_KIND = /^x-[a-z0-9-]+$/;
|
|
8
|
+
/** The service one context's registry lives under; a second copy of this package sees another. */
|
|
9
|
+
export const REGISTRY_SERVICE = 'dsbot-channel.registry';
|
|
10
|
+
const ORDER = `mount the adapter plugin between "dsbot-channel/registry" and "dsbot-channel"`;
|
|
11
|
+
/** Refuses a kind a profile declared that cannot identify a transport. */
|
|
12
|
+
export function checkAdapterKind(kind) {
|
|
13
|
+
if (BUILTIN_KINDS.includes(kind))
|
|
14
|
+
return;
|
|
15
|
+
if (kind.length > ADAPTER_KIND_MAX || !THIRD_PARTY_KIND.test(kind)) {
|
|
16
|
+
throw new ConfigError(`adapter kind "${kind}" must be a built-in name or match x-[a-z0-9-]+ within ${ADAPTER_KIND_MAX} characters`, ['adapters', 'kind']);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** The service an adapter plugin hands its own configured instance to; the gateway mounts or queues it. */
|
|
20
|
+
export const MOUNT_SERVICE = 'dsbot-channel.mount';
|
|
21
|
+
/** One context's transports: a third-party plugin and the gateway share this instance, no module state. */
|
|
22
|
+
export class AdapterRegistry {
|
|
23
|
+
#factories = new Map();
|
|
24
|
+
#instances = [];
|
|
25
|
+
/** Queues one configured instance; the gateway drains it at start. */
|
|
26
|
+
addInstance(instance) {
|
|
27
|
+
this.#instances.push(instance);
|
|
28
|
+
}
|
|
29
|
+
/** Hands the gateway every queued instance. */
|
|
30
|
+
takeInstances() {
|
|
31
|
+
return this.#instances.splice(0);
|
|
32
|
+
}
|
|
33
|
+
/** Lets a third-party transport offer itself; its kind must stay in the `x-` namespace. One call per kind. */
|
|
34
|
+
register(kind, factory) {
|
|
35
|
+
if (BUILTIN_KINDS.includes(kind)) {
|
|
36
|
+
throw new ConfigError(`adapter kind "${kind}" is reserved for the built-in transport; a third-party kind starts with "x-"`, ['adapters', 'kind']);
|
|
37
|
+
}
|
|
38
|
+
if (kind.length > ADAPTER_KIND_MAX || !THIRD_PARTY_KIND.test(kind)) {
|
|
39
|
+
throw new ConfigError(`a third-party adapter kind must match x-[a-z0-9-]+ and be at most ${ADAPTER_KIND_MAX} characters: "${kind}"`, ['adapters', 'kind']);
|
|
40
|
+
}
|
|
41
|
+
this.add(kind, factory);
|
|
42
|
+
}
|
|
43
|
+
/** Registers a kind the package ships; only the gateway calls it, with its own copies. */
|
|
44
|
+
builtin(kind, factory) {
|
|
45
|
+
if (!BUILTIN_KINDS.includes(kind)) {
|
|
46
|
+
throw new ConfigError(`"${kind}" is not a built-in adapter kind`, ['adapters', 'kind']);
|
|
47
|
+
}
|
|
48
|
+
this.add(kind, factory);
|
|
49
|
+
}
|
|
50
|
+
has(kind) {
|
|
51
|
+
return this.#factories.has(kind);
|
|
52
|
+
}
|
|
53
|
+
/** The kinds registered in this context, for a message a user can act on. */
|
|
54
|
+
known() {
|
|
55
|
+
return [...this.#factories.keys()];
|
|
56
|
+
}
|
|
57
|
+
/** The factory for a declared kind, or a refusal naming what is available and where the plugin goes. */
|
|
58
|
+
factory(kind) {
|
|
59
|
+
const factory = this.#factories.get(kind);
|
|
60
|
+
if (factory !== undefined)
|
|
61
|
+
return factory;
|
|
62
|
+
const known = this.known();
|
|
63
|
+
throw new ConfigError(`no adapter serves kind "${kind}"${known.length === 0 ? '' : `; known: ${known.join(', ')}`}; ${ORDER}`, ['adapters', 'kind']);
|
|
64
|
+
}
|
|
65
|
+
add(kind, factory) {
|
|
66
|
+
if (this.#factories.has(kind)) {
|
|
67
|
+
throw new ConfigError(`adapter kind "${kind}" is already registered`, ['adapters', 'kind']);
|
|
68
|
+
}
|
|
69
|
+
this.#factories.set(kind, factory);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** The only supported way for a third-party package to offer a transport. */
|
|
73
|
+
export function defineAdapterPlugin(options) {
|
|
74
|
+
return {
|
|
75
|
+
name: `dsbot-channel/adapter/${options.kind}`,
|
|
76
|
+
inject: [REGISTRY_SERVICE],
|
|
77
|
+
apply(ctx) {
|
|
78
|
+
const registry = ctx.get(REGISTRY_SERVICE);
|
|
79
|
+
// Defensive only: `inject` keeps this plugin from running before the registry exists.
|
|
80
|
+
if (registry === undefined) {
|
|
81
|
+
throw new ConfigError(`adapter "${options.kind}" found no registry: mount "dsbot-channel/registry" before this plugin`, ['adapters', 'kind']);
|
|
82
|
+
}
|
|
83
|
+
registry.register(options.kind, options.create);
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
}
|