@mindot/will 0.3.0 → 0.5.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/README.md +63 -21
- package/dist/channels/discord.d.ts +69 -0
- package/dist/channels/discord.js +193 -0
- package/dist/channels/discord.js.map +1 -0
- package/dist/channels/whatsapp.d.ts +72 -0
- package/dist/channels/whatsapp.js +252 -0
- package/dist/channels/whatsapp.js.map +1 -0
- package/dist/cli.js +904 -356
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +141 -141
- package/dist/index.js +441 -342
- package/dist/index.js.map +1 -1
- package/dist/mcp/effectors.d.ts +1 -1
- package/dist/types-E9-HV-SW.d.ts +11 -0
- package/dist/{will-B5eKs3Wv.d.ts → will-BDq-TMQr.d.ts} +4013 -3916
- package/package.json +17 -3
- package/src/channels/discord.ts +214 -0
- package/src/channels/roster.ts +87 -0
- package/src/channels/types.ts +46 -0
- package/src/channels/whatsapp.ts +318 -0
- package/src/cli.ts +57 -9
- package/src/cognition/agency/engines/deliberation.engine.ts +7 -7
- package/src/cognition/agency/execution.primitives.ts +11 -11
- package/src/cognition/agency/proactive.communicator.ts +8 -8
- package/src/cognition/config.mirror.entities.ts +2 -2
- package/src/cognition/conversation.memory.ts +1 -1
- package/src/cognition/faculties/executive.engine/commands.ts +7 -15
- package/src/cognition/faculties/executive.engine/engine.ts +66 -35
- package/src/cognition/faculties/executive.engine/escalation.buffer.ts +1 -1
- package/src/cognition/faculties/executive.engine/facet.ts +1 -1
- package/src/cognition/faculties/executive.engine/prompt.factory.ts +76 -61
- package/src/cognition/faculties/executive.engine/types.ts +1 -1
- package/src/cognition/faculties/introspection.engine.ts +1 -2
- package/src/cognition/faculties/planning.engine/engine.ts +38 -1
- package/src/cognition/faculties/planning.engine/plan.store.ts +42 -0
- package/src/cognition/faculties/planning.engine/plan.supervision.ts +7 -7
- package/src/cognition/faculties/theory.of.mind.ts +2 -2
- package/src/cognition/senses/audition.engine/engine.ts +21 -21
- package/src/cognition/utilities/token.tracker.ts +6 -0
- package/src/host/boot.ts +95 -7
- package/src/llm/index.ts +75 -25
- package/src/llm/summarizer.ts +2 -2
- package/src/profiles/companion.ts +14 -14
- package/src/profiles/company-brain.ts +19 -19
- package/src/profiles/customer-service.ts +17 -17
- package/src/profiles/game-npc.ts +10 -10
- package/src/profiles/index.ts +2 -2
- package/src/profiles/smart-home.ts +16 -16
- package/src/runners/outreach.runner.ts +6 -9
- package/src/runners/social.runner.ts +1 -4
- package/src/runners/thin-shim.runner.ts +4 -6
- package/src/sdk/will.ts +42 -16
- package/src/stem/guards/identity.coherence.ts +9 -6
- package/src/stem/guards/identity.guard.ts +20 -9
- package/src/stem/index.ts +7 -7
- package/src/stem/mind.ts +182 -98
- package/src/stem/tracts/outbox.controller.ts +2 -2
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ Regulatory → Perceptual → Affective → Memory → Executive → Meta-cognit
|
|
|
19
19
|
|
|
20
20
|
The mind is not re-derived from a prompt each run. It **accretes**: traits develop from experience, beliefs consolidate, skills proceduralise, and a coherent self carries across restarts as a portable, eval-verified artifact.
|
|
21
21
|
|
|
22
|
-
Embed it
|
|
22
|
+
Embed it six ways — [**SDK facade**](#the-will-sdk-facade--recommended) (Node/TS) · [**Discord**](#a-will-in-your-discord-server--will-discord) (a mind in your server) · [**WhatsApp**](docs/channels/whatsapp.md) (QR-pair a linked device) · [**Claude Desktop via MCP**](#the-mcp-server--a-persistent-mind-in-claude-desktop--claude-code) · [**MCP tools as its abilities**](#employing-mcp-tools--the-mind-gets-abilities) · [**HTTP sidecar / Docker**](#the-http-sidecar--will-serve-any-language-or-docker) (any language). [Pick a surface →](#use-it-in-your-project)
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
@@ -84,11 +84,13 @@ ticks — or earlier when physiology demands it.
|
|
|
84
84
|
|
|
85
85
|
## Use it in your project
|
|
86
86
|
|
|
87
|
-
Runs anywhere **Node 18+ or Bun** runs (the engine is Node-compatible; Bun is the primary target). **
|
|
87
|
+
Runs anywhere **Node 18+ or Bun** runs (the engine is Node-compatible; Bun is the primary target). **Six surfaces**, one paradigm — you *perceive* things to a mind and *observe what it projects*; it may act, speak, or stay silent, and it persists across restarts via its [PMA artifact](#pma--the-persistent-mind-artifact):
|
|
88
88
|
|
|
89
89
|
| Surface | You are… | Start with |
|
|
90
90
|
|---|---|---|
|
|
91
91
|
| [**SDK facade**](#the-will-sdk-facade--recommended) | a Node/TypeScript app embedding a mind | `import { Will } from '@mindot/will'` |
|
|
92
|
+
| [**Discord**](#a-will-in-your-discord-server--will-discord) | a server where a mind should *live* | `npx -y @mindot/will discord` |
|
|
93
|
+
| [**WhatsApp**](docs/channels/whatsapp.md) | the chats people actually check — via a QR-paired linked device (unofficial; read the guide's warning) | `npx -y @mindot/will whatsapp` |
|
|
92
94
|
| [**MCP host**](#the-mcp-server--a-persistent-mind-in-claude-desktop--claude-code) | Claude Desktop / Claude Code / an IDE | `npx -y @mindot/will mcp` |
|
|
93
95
|
| [**MCP tools as abilities**](#employing-mcp-tools--the-mind-gets-abilities) | giving the mind tools it *chooses* to use | `import { connectMcpEffectors } from '@mindot/will/mcp'` |
|
|
94
96
|
| [**HTTP sidecar**](#the-http-sidecar--will-serve-any-language-or-docker) | Python, Go, a game server — any language, or Docker | `npx -y @mindot/will serve` |
|
|
@@ -125,6 +127,20 @@ const revived = await Will.wake(pma, { name: 'Aria' })
|
|
|
125
127
|
|
|
126
128
|
`will.state()` returns a compact read of the mind (energy, mood, goals, beliefs, self-narrative). Drop to `will.stem` for the full `WillStem` contract at any time. Runnable: [`examples/effectors.ts`](examples/effectors.ts).
|
|
127
129
|
|
|
130
|
+
### A Will in your Discord server — `will discord`
|
|
131
|
+
|
|
132
|
+
Two minutes from a bot token to a persistent mind *living* in your server — not a command bot:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
DISCORD_BOT_TOKEN=… WILL_NAME=Aria \
|
|
136
|
+
WILL_IDENTITY="I am Aria — curious, dry-witted, fond of this server's people." \
|
|
137
|
+
ANTHROPIC_API_KEY=sk-ant-… npx -y @mindot/will discord
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
No command prefix, no forced replies. It perceives the rooms it can see (salience-scored — perceiving costs no LLM call), **decides for itself when to speak** (silence is a valid outcome), learns people's names as *learned* knowledge (`discord:<userId>` is one entity across servers), keeps each channel as its own conversation thread, and **can message first** — proactive utterances route to the addressee's last shared channel, then their DM, then `WILL_DISCORD_HOME_CHANNEL`. On shutdown it hibernates to its PMA and returns as the same self, still knowing everyone.
|
|
141
|
+
|
|
142
|
+
Scope it with `WILL_DISCORD_CHANNELS` (id allowlist) and `WILL_DISCORD_MENTION_ONLY` for busy servers. The bridge grants no tools — it is a mouth and ears, not hands; abilities stay explicit (effectors / MCP). Setup + SDK embedding (`import { connectDiscord } from '@mindot/will/discord'`): [docs/channels/discord.md](docs/channels/discord.md) · runnable: [`examples/discord.ts`](examples/discord.ts).
|
|
143
|
+
|
|
128
144
|
### The MCP server — a persistent mind in Claude Desktop / Claude Code
|
|
129
145
|
|
|
130
146
|
Host a Will over the [Model Context Protocol](https://modelcontextprotocol.io) — any MCP client can then live alongside a persistent mind that **remembers across sessions** (it hibernates to a Persistent Mind Artifact on shutdown and wakes as the same self on the next boot):
|
|
@@ -144,7 +160,7 @@ Host a Will over the [Model Context Protocol](https://modelcontextprotocol.io)
|
|
|
144
160
|
}
|
|
145
161
|
```
|
|
146
162
|
|
|
147
|
-
The surface keeps the paradigm: `perceive` delivers a stimulus (it returns when *delivered*, not answered), `next_utterance` awaits the mind's next words (**silence is a valid outcome**, reported — never an error), `state` reads its inner life, and `save` checkpoints it without stopping it. There is deliberately no `ask()`-shaped tool. Config via env: `
|
|
163
|
+
The surface keeps the paradigm: `perceive` delivers a stimulus (it returns when *delivered*, not answered), `next_utterance` awaits the mind's next words (**silence is a valid outcome**, reported — never an error), `state` reads its inner life, and `save` checkpoints it without stopping it. There is deliberately no `ask()`-shaped tool. Config via env: `WILL_ANATOMY` (mind|reflex — reflex is the no-LLM shell), `WILL_LLM_MODEL` (concrete model id), `WILL_LLM` (mock|anthropic — defaults to the zero-key mock unless `ANTHROPIC_API_KEY` is set), `WILL_TICK_MS`, `WILL_PMA_PATH`.
|
|
148
164
|
|
|
149
165
|
### Employing MCP tools — the mind gets abilities
|
|
150
166
|
|
|
@@ -204,8 +220,7 @@ const config: WillConfig = {
|
|
|
204
220
|
traits: { conscientiousness: 0.9, neuroticism: 0.4 },
|
|
205
221
|
style: 'measured and warm',
|
|
206
222
|
},
|
|
207
|
-
|
|
208
|
-
modelTier: 'sonnet', // which model the executive recruits
|
|
223
|
+
model: 'claude-sonnet-4-5-20250929', // concrete model id (optional — env/provider default otherwise)
|
|
209
224
|
allowedGenericEffectors: ['listen', 'talk', 'text'], // opt in to communication
|
|
210
225
|
persistentMemory: true,
|
|
211
226
|
snapshotInterval: 10,
|
|
@@ -239,6 +254,10 @@ There is **no synchronous reply** — `ingestText` returns immediately; the Will
|
|
|
239
254
|
|
|
240
255
|
## Architecture
|
|
241
256
|
|
|
257
|
+
> **Visual map:** [`docs/graphs/`](docs/graphs/) holds twenty-one architecture graphs — the cognitive stories (memory, executive & facets, agency, audition, body & affect, meta-cognition, the executive ⇄ agency seam, planning & goals, social cognition, proactive communication, competence, the two persona channels), the machinery (the deterministic tick, the simulation core, the cognitive bus & wiring, one LLM call end-to-end, transports, the stem's tracts), the edges (host surfaces, the PMA lifecycle), and the [whole composition](docs/graphs/composition.svg). One palette across all of them: violet is always memory, amber executive, green agency. Regenerate with `bun docs/graphs/generate.ts`.
|
|
258
|
+
|
|
259
|
+

|
|
260
|
+
|
|
242
261
|
### The cognitive engines
|
|
243
262
|
|
|
244
263
|
**40+ engines** run on the tick clock: **38 faculties** across seven systems, the **six-engine agency pipeline**, and **five sense engines**. The vast majority resolve every tick with no LLM call.
|
|
@@ -330,7 +349,7 @@ Beyond the five communication effectors, your world can expose **domain actions*
|
|
|
330
349
|
registerProfile({
|
|
331
350
|
id: 'rover', name: 'Rover', description: 'A field robot.',
|
|
332
351
|
effectors: ['listen', 'talk', 'move', 'scan', 'grab'],
|
|
333
|
-
context: '
|
|
352
|
+
context: 'I am a rover exploring terrain. I can move, scan, and grab samples.',
|
|
334
353
|
})
|
|
335
354
|
|
|
336
355
|
// 2. When the Will chooses one, it appears in pendingEffectorInvocations.
|
|
@@ -458,7 +477,7 @@ registerProfile({
|
|
|
458
477
|
name: 'Research Lab',
|
|
459
478
|
description: 'An observable mind for studying emergent cognition.',
|
|
460
479
|
effectors: ['listen', 'talk', 'remember', 'reflect'],
|
|
461
|
-
context: '
|
|
480
|
+
context: 'I am a research subject. I report my reasoning transparently…',
|
|
462
481
|
})
|
|
463
482
|
|
|
464
483
|
// Then in WillConfig: { ..., profile: 'research-lab' }
|
|
@@ -506,10 +525,10 @@ const config: WillConfig = {
|
|
|
506
525
|
|
|
507
526
|
// REQUIRED. How many cognitive layers run (see WillConfig reference below).
|
|
508
527
|
// 'full' runs the complete mind — the normal choice.
|
|
509
|
-
|
|
528
|
+
|
|
510
529
|
|
|
511
530
|
// REQUIRED. Which model the executive recruits when it fires.
|
|
512
|
-
|
|
531
|
+
model: 'claude-sonnet-4-5-20250929',
|
|
513
532
|
|
|
514
533
|
// Communication effectors this Will is permitted to use.
|
|
515
534
|
// Omit or set null for a Will with no communication surface.
|
|
@@ -535,8 +554,8 @@ Traits seed the PersonaPrior as a *starting disposition*, not a fixed personalit
|
|
|
535
554
|
| `id` | ✅ | — | Unique identifier — thread key and filesystem path segment |
|
|
536
555
|
| `name` | ✅ | — | Human-readable display name |
|
|
537
556
|
| `identity` | ✅ | — | Persona: `{ prompt, values[], traits{}, style }` (Layer 2) |
|
|
538
|
-
| `
|
|
539
|
-
| `
|
|
557
|
+
| `anatomy` | — | `'mind'` | `'mind' \| 'reflex'` — the only structural variant. `mind` runs the whole architecture; `reflex` is a no-LLM shell (regulatory + senses + agency heuristics) for embedded use. Faculties are not a pricing axis — budgets are. |
|
|
558
|
+
| `model` | — | env / provider default | Concrete LLM model id (e.g. `'claude-sonnet-4-5-20250929'`). `WILL_LLM_MODEL` env pins over this. Product tier labels resolve to a concrete id host-side, before the engine. |
|
|
540
559
|
| `persistentMemory` | ✅ | — | Persist snapshots so beliefs/goals/narrative survive restarts |
|
|
541
560
|
| `snapshotInterval` | ✅ | — | Ticks between in-memory snapshots |
|
|
542
561
|
| `profile` | — | `null` | World profile preset (effectors + environment context). Merged with `allowedGenericEffectors` |
|
|
@@ -760,11 +779,12 @@ cd will && bun run build
|
|
|
760
779
|
|
|
761
780
|
| Variable | Default | Description |
|
|
762
781
|
|---|---|---|
|
|
763
|
-
| `WILL_LLM_PROVIDER` | `anthropic` | `anthropic` supported today; `openai` · `deepseek` · `google` scaffolded |
|
|
764
|
-
| `WILL_LLM_MODEL` | *(
|
|
765
|
-
| `WILL_LLM_API_KEY` | — | API key for the chosen provider. Falls back to `ANTHROPIC_API_KEY` |
|
|
766
|
-
| `
|
|
767
|
-
| `
|
|
782
|
+
| `WILL_LLM_PROVIDER` | `anthropic` | `anthropic` · `glm` supported today; `openai` · `deepseek` · `google` scaffolded |
|
|
783
|
+
| `WILL_LLM_MODEL` | *(provider default)* | Model id for the chosen provider (`claude-sonnet-4-5-20250929` / `glm-5.2`) |
|
|
784
|
+
| `WILL_LLM_API_KEY` | — | API key for the chosen provider. Falls back to `ANTHROPIC_API_KEY` (or `ZAI_API_KEY` on `glm`) |
|
|
785
|
+
| `ZAI_API_KEY` | — | Z.ai key. Its presence alone selects the `glm` provider when `WILL_LLM` is unset |
|
|
786
|
+
| `WILL_LLM_BASE_URL` | *(provider default)* | Override the provider API base URL (e.g. a self-hosted GLM at `http://localhost:8000/anthropic`). Falls back to `OPENAI_BASE_URL` |
|
|
787
|
+
| `WILL_LLM_TIMEOUT_MS` | `90000` | LLM timeout. On the Anthropic-wire providers (`anthropic`, `glm` — both streaming) this is a *first-byte*/TTFT deadline — long completions aren't aborted mid-generation |
|
|
768
788
|
| `WILL_LLM_CONCURRENCY` | `3` | Max concurrent LLM calls (min 3: executive + conversation + summary) |
|
|
769
789
|
| `WILL_TICK_MS` | `1000` | Milliseconds between ticks |
|
|
770
790
|
| `WILL_MAX_TICKS` | `0` | Stop after N ticks. `0` = run forever |
|
|
@@ -782,18 +802,40 @@ cd will && bun run build
|
|
|
782
802
|
| `WILL_EMBEDDING_MODEL` | `text-embedding-3-small` *(when keyed)* | Embedding model for episodic recall; `none` disables |
|
|
783
803
|
| `WILL_EMBEDDING_URL` | *(provider default)* | Base URL for an OpenAI-compatible embedding endpoint |
|
|
784
804
|
| `WILL_TRANSPORT` | `off` | Delivery mode used by the host: `off` (outbox polling) · `stream` (in-process) · `socketio` (peer) |
|
|
805
|
+
| `DISCORD_BOT_TOKEN` | — | Bot token for `will discord` (see [docs/channels/discord.md](docs/channels/discord.md)) |
|
|
806
|
+
| `WILL_DISCORD_CHANNELS` | *(all visible)* | Comma-separated channel ids the Will inhabits |
|
|
807
|
+
| `WILL_DISCORD_MENTION_ONLY` | `false` | Perceive guild messages only when @mentioned (DMs always perceived) |
|
|
808
|
+
| `WILL_DISCORD_HOME_CHANNEL` | — | Fallback channel for utterances with no reachable addressee |
|
|
809
|
+
| `WILL_WHATSAPP_CHATS` | *(all chats)* | Comma-separated chat jids the Will inhabits (`will whatsapp` — [guide + warning](docs/channels/whatsapp.md)) |
|
|
810
|
+
| `WILL_WHATSAPP_MENTION_ONLY` | `false` | Perceive group messages only when @mentioned (DMs always perceived) |
|
|
811
|
+
| `WILL_WHATSAPP_HOME_CHAT` | — | Fallback chat jid for utterances with no reachable addressee |
|
|
785
812
|
| `OPENAI_BASE_URL` | — | Base URL override for local / OpenAI-compatible models |
|
|
786
813
|
|
|
787
814
|
---
|
|
788
815
|
|
|
789
816
|
## LLM provider
|
|
790
817
|
|
|
791
|
-
**
|
|
818
|
+
**Two providers are supported today** — Anthropic and Z.ai's GLM. Both speak the Anthropic Messages wire, so both get the full path: token streaming, the first-byte (TTFT) deadline, prompt-cache breakpoints, and the structured-output contract.
|
|
792
819
|
|
|
793
|
-
| Provider | `WILL_LLM_PROVIDER` | Status |
|
|
794
|
-
|
|
795
|
-
| Anthropic | `anthropic` | ✅ Supported — streaming, structured output |
|
|
796
|
-
|
|
|
820
|
+
| Provider | `WILL_LLM_PROVIDER` | Key | Status |
|
|
821
|
+
|---|---|---|---|
|
|
822
|
+
| Anthropic | `anthropic` | `ANTHROPIC_API_KEY` | ✅ Supported — streaming, structured output |
|
|
823
|
+
| **Z.ai — GLM** | `glm` | `ZAI_API_KEY` | ✅ Supported — streaming, structured output (via Z.ai's Anthropic-compatible endpoint) |
|
|
824
|
+
| OpenAI · DeepSeek · Google | `openai` · `deepseek` · `google` | `WILL_LLM_API_KEY` | ⚠️ Scaffolded — code paths exist (non-streaming), not yet production-ready |
|
|
825
|
+
|
|
826
|
+
### Running a mind on GLM
|
|
827
|
+
|
|
828
|
+
```bash
|
|
829
|
+
ZAI_API_KEY=… WILL_NAME=Aria npx -y @mindot/will discord
|
|
830
|
+
```
|
|
831
|
+
|
|
832
|
+
That's all — the mode auto-detects from the key present, `WILL_LLM_PROVIDER=glm` defaults to `https://api.z.ai/api/anthropic`, and the model defaults to `glm-5.2`. Pin the 1M-context variant with `WILL_LLM_MODEL=glm-5.2[1m]`.
|
|
833
|
+
|
|
834
|
+
Why it matters for a Will specifically: a mind is **always on**. It reasons every N ticks whether or not anyone spoke, so the executive is a standing cost, not a per-request one — the arithmetic that makes a cheaper capable model matter more here than in a request/response agent. GLM-5.2 runs about **$1.40 / $4.40** per Mtok against Claude Sonnet's $3 / $15, with a 1M context.
|
|
835
|
+
|
|
836
|
+
`WILL_LLM_BASE_URL` points `glm` at any **Anthropic-compatible** endpoint — a gateway (LiteLLM, claude-code-router), or one fronting the open weights (GLM-5.2 is MIT-licensed). Note the wire, not just the model: vLLM/SGLang serve an *OpenAI*-shaped API, so a bare self-host belongs on `WILL_LLM_PROVIDER=openai` — today's scaffold, no streaming — until an Anthropic-compatible shim sits in front.
|
|
837
|
+
|
|
838
|
+
A Will's identity survives the swap either way: the [PMA](#pma--the-persistent-mind-artifact) carries the self across a model change, and `runPMAEval` scores how faithfully — continuity across providers is measurable, not asserted.
|
|
797
839
|
|
|
798
840
|
The provider layer is an in-house `fetch` client (`src/llm/index.ts`) with a global concurrency gate (`src/llm/gate.ts`) — no Mastra / ai-sdk runtime dependency.
|
|
799
841
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { W as Will } from '../will-BDq-TMQr.js';
|
|
2
|
+
import { C as ChannelBridge } from '../types-E9-HV-SW.js';
|
|
3
|
+
|
|
4
|
+
interface DiscordLikeChannel {
|
|
5
|
+
send(content: string): Promise<unknown>;
|
|
6
|
+
sendTyping?(): Promise<unknown>;
|
|
7
|
+
}
|
|
8
|
+
interface DiscordLikeMessage {
|
|
9
|
+
content: string;
|
|
10
|
+
cleanContent?: string;
|
|
11
|
+
channelId: string;
|
|
12
|
+
guildId?: string | null;
|
|
13
|
+
author: {
|
|
14
|
+
id: string;
|
|
15
|
+
bot?: boolean;
|
|
16
|
+
username?: string;
|
|
17
|
+
displayName?: string;
|
|
18
|
+
};
|
|
19
|
+
member?: {
|
|
20
|
+
displayName?: string;
|
|
21
|
+
} | null;
|
|
22
|
+
mentions?: {
|
|
23
|
+
has(userId: string): boolean;
|
|
24
|
+
};
|
|
25
|
+
channel: DiscordLikeChannel;
|
|
26
|
+
}
|
|
27
|
+
interface DiscordLikeClient {
|
|
28
|
+
user: {
|
|
29
|
+
id: string;
|
|
30
|
+
setPresence?(p: unknown): void;
|
|
31
|
+
} | null;
|
|
32
|
+
/** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */
|
|
33
|
+
isReady?(): boolean;
|
|
34
|
+
on(event: 'messageCreate', fn: (m: DiscordLikeMessage) => void): unknown;
|
|
35
|
+
once(event: string, fn: () => void): unknown;
|
|
36
|
+
login(token: string): Promise<unknown>;
|
|
37
|
+
destroy(): Promise<unknown> | void;
|
|
38
|
+
channels: {
|
|
39
|
+
fetch(id: string): Promise<unknown>;
|
|
40
|
+
};
|
|
41
|
+
users: {
|
|
42
|
+
fetch(id: string): Promise<{
|
|
43
|
+
send(content: string): Promise<unknown>;
|
|
44
|
+
}>;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
interface DiscordBridgeOptions {
|
|
48
|
+
/** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */
|
|
49
|
+
token?: string;
|
|
50
|
+
/** Channel ids the Will inhabits. Unset = every channel it can see. */
|
|
51
|
+
channels?: string[];
|
|
52
|
+
/** Perceive guild messages only when the Will is @mentioned (DMs always perceived). */
|
|
53
|
+
mentionOnly?: boolean;
|
|
54
|
+
/** Fallback channel for utterances with no reachable addressee. */
|
|
55
|
+
homeChannelId?: string;
|
|
56
|
+
/** Roster path (default: ./.will/<willId>.discord.json). */
|
|
57
|
+
rosterPath?: string;
|
|
58
|
+
/** Test / power-user seam: bring your own client; discord.js is never imported. */
|
|
59
|
+
client?: DiscordLikeClient;
|
|
60
|
+
log?: (msg: string) => void;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Connect a Will to Discord. Resolves once the bridge is live (logged in and
|
|
64
|
+
* relaying). Close it via the returned `ChannelBridge.close()` — the Will
|
|
65
|
+
* itself is not stopped; it simply loses this surface.
|
|
66
|
+
*/
|
|
67
|
+
declare function connectDiscord(will: Will, opts: DiscordBridgeOptions): Promise<ChannelBridge>;
|
|
68
|
+
|
|
69
|
+
export { type DiscordBridgeOptions, type DiscordLikeChannel, type DiscordLikeClient, type DiscordLikeMessage, connectDiscord };
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname } from 'path';
|
|
3
|
+
|
|
4
|
+
// src/channels/roster.ts
|
|
5
|
+
var FLUSH_MS = 2e3;
|
|
6
|
+
var ChannelRoster = class {
|
|
7
|
+
constructor(path) {
|
|
8
|
+
this.path = path;
|
|
9
|
+
if (existsSync(path)) {
|
|
10
|
+
try {
|
|
11
|
+
const raw = JSON.parse(readFileSync(path, "utf8"));
|
|
12
|
+
for (const e of Array.isArray(raw) ? raw : []) this.entries.set(e.entityId, e);
|
|
13
|
+
} catch {
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
path;
|
|
18
|
+
entries = /* @__PURE__ */ new Map();
|
|
19
|
+
dirty = false;
|
|
20
|
+
timer = null;
|
|
21
|
+
/** Upsert what we just learned about an entity; schedules a throttled flush. */
|
|
22
|
+
record(update) {
|
|
23
|
+
const prev = this.entries.get(update.entityId);
|
|
24
|
+
const next = {
|
|
25
|
+
lastSeenAt: Date.now(),
|
|
26
|
+
...prev,
|
|
27
|
+
...Object.fromEntries(Object.entries(update).filter(([, v]) => v !== void 0))
|
|
28
|
+
};
|
|
29
|
+
this.entries.set(next.entityId, next);
|
|
30
|
+
this.dirty = true;
|
|
31
|
+
if (!this.timer) {
|
|
32
|
+
this.timer = setTimeout(() => {
|
|
33
|
+
this.timer = null;
|
|
34
|
+
this.flush();
|
|
35
|
+
}, FLUSH_MS);
|
|
36
|
+
this.timer.unref?.();
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
resolve(entityId) {
|
|
41
|
+
return this.entries.get(entityId);
|
|
42
|
+
}
|
|
43
|
+
all() {
|
|
44
|
+
return [...this.entries.values()];
|
|
45
|
+
}
|
|
46
|
+
/** Write to disk now (no-op when clean). Called by bridges on close. */
|
|
47
|
+
flush() {
|
|
48
|
+
if (!this.dirty) return;
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(dirname(this.path), { recursive: true });
|
|
51
|
+
writeFileSync(this.path, JSON.stringify(this.all(), null, 2));
|
|
52
|
+
this.dirty = false;
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// src/channels/types.ts
|
|
59
|
+
function chunkText(text, max) {
|
|
60
|
+
if (text.length <= max) return [text];
|
|
61
|
+
const chunks = [];
|
|
62
|
+
let rest = text;
|
|
63
|
+
while (rest.length > max) {
|
|
64
|
+
const window = rest.slice(0, max);
|
|
65
|
+
const cut = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"), window.lastIndexOf(" "));
|
|
66
|
+
const at = cut > max * 0.5 ? cut : max;
|
|
67
|
+
chunks.push(rest.slice(0, at).trimEnd());
|
|
68
|
+
rest = rest.slice(at).trimStart();
|
|
69
|
+
}
|
|
70
|
+
if (rest) chunks.push(rest);
|
|
71
|
+
return chunks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/channels/discord.ts
|
|
75
|
+
var DISCORD_MESSAGE_LIMIT = 2e3;
|
|
76
|
+
async function connectDiscord(will, opts) {
|
|
77
|
+
const log = opts.log ?? ((m) => console.error(`[will:discord] ${m}`));
|
|
78
|
+
const roster = new ChannelRoster(opts.rosterPath ?? `.will/${will.id}.discord.json`);
|
|
79
|
+
const allowed = opts.channels?.length ? new Set(opts.channels) : null;
|
|
80
|
+
const client = opts.client ?? await createDiscordClient();
|
|
81
|
+
let lastActiveChannelId = opts.homeChannelId ?? null;
|
|
82
|
+
client.on("messageCreate", (message) => {
|
|
83
|
+
void onMessage(message);
|
|
84
|
+
});
|
|
85
|
+
async function onMessage(message) {
|
|
86
|
+
const self = client.user;
|
|
87
|
+
if (!self || message.author.id === self.id || message.author.bot) return;
|
|
88
|
+
const isDM = !message.guildId;
|
|
89
|
+
if (!isDM && allowed && !allowed.has(message.channelId)) return;
|
|
90
|
+
const addressed = isDM || (message.mentions?.has(self.id) ?? false);
|
|
91
|
+
if (opts.mentionOnly && !addressed) return;
|
|
92
|
+
const entityId = `discord:${message.author.id}`;
|
|
93
|
+
const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username;
|
|
94
|
+
roster.record({
|
|
95
|
+
entityId,
|
|
96
|
+
userId: message.author.id,
|
|
97
|
+
...speaker ? { displayName: speaker } : {},
|
|
98
|
+
...isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId }
|
|
99
|
+
});
|
|
100
|
+
if (!isDM) lastActiveChannelId = message.channelId;
|
|
101
|
+
if (addressed) await message.channel.sendTyping?.().catch(() => {
|
|
102
|
+
});
|
|
103
|
+
const text = message.cleanContent || message.content;
|
|
104
|
+
if (!text.trim()) return;
|
|
105
|
+
await will.perceive({
|
|
106
|
+
text,
|
|
107
|
+
from: entityId,
|
|
108
|
+
thread: `discord:${message.channelId}`,
|
|
109
|
+
...speaker ? { speaker } : {}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let closed = false;
|
|
113
|
+
will.on("message", (m) => {
|
|
114
|
+
if (!closed) void deliver(m);
|
|
115
|
+
});
|
|
116
|
+
async function deliver(m) {
|
|
117
|
+
const peer = m.to ? roster.resolve(m.to) : void 0;
|
|
118
|
+
const chunks = chunkText(m.content, DISCORD_MESSAGE_LIMIT);
|
|
119
|
+
const channelIds = [peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? void 0, lastActiveChannelId ?? void 0];
|
|
120
|
+
for (const id of channelIds) {
|
|
121
|
+
if (!id) continue;
|
|
122
|
+
try {
|
|
123
|
+
const channel = await client.channels.fetch(id);
|
|
124
|
+
if (!channel?.send) continue;
|
|
125
|
+
for (const chunk of chunks) await channel.send(chunk);
|
|
126
|
+
return;
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (peer) {
|
|
131
|
+
try {
|
|
132
|
+
const user = await client.users.fetch(peer.userId);
|
|
133
|
+
for (const chunk of chunks) await user.send(chunk);
|
|
134
|
+
return;
|
|
135
|
+
} catch {
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
log(`no route for utterance to '${m.to}' \u2014 dropped (${m.content.length} chars)`);
|
|
139
|
+
}
|
|
140
|
+
const bridge = {
|
|
141
|
+
kind: "discord",
|
|
142
|
+
async start() {
|
|
143
|
+
if (!client.user) {
|
|
144
|
+
const ready = new Promise((resolve) => {
|
|
145
|
+
let poll = null;
|
|
146
|
+
const done = () => {
|
|
147
|
+
if (poll) clearInterval(poll);
|
|
148
|
+
resolve();
|
|
149
|
+
};
|
|
150
|
+
client.once("clientReady", done);
|
|
151
|
+
poll = setInterval(() => {
|
|
152
|
+
if (client.isReady?.()) done();
|
|
153
|
+
}, 100);
|
|
154
|
+
poll.unref?.();
|
|
155
|
+
});
|
|
156
|
+
await client.login(opts.token ?? "");
|
|
157
|
+
await ready;
|
|
158
|
+
}
|
|
159
|
+
log(`${will.name} is present on Discord as user ${client.user?.id}`);
|
|
160
|
+
},
|
|
161
|
+
async close() {
|
|
162
|
+
if (closed) return;
|
|
163
|
+
closed = true;
|
|
164
|
+
roster.flush();
|
|
165
|
+
await Promise.resolve(client.destroy()).catch(() => {
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
return bridge;
|
|
170
|
+
}
|
|
171
|
+
async function createDiscordClient() {
|
|
172
|
+
let mod;
|
|
173
|
+
try {
|
|
174
|
+
mod = await import('discord.js');
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error("discord.js is not installed (it is an optionalDependency) \u2014 run `bun add discord.js` / `npm i discord.js` and retry.");
|
|
177
|
+
}
|
|
178
|
+
const { Client, GatewayIntentBits, Partials } = mod;
|
|
179
|
+
return new Client({
|
|
180
|
+
intents: [
|
|
181
|
+
GatewayIntentBits.Guilds,
|
|
182
|
+
GatewayIntentBits.GuildMessages,
|
|
183
|
+
GatewayIntentBits.MessageContent,
|
|
184
|
+
GatewayIntentBits.DirectMessages
|
|
185
|
+
],
|
|
186
|
+
partials: [Partials.Channel]
|
|
187
|
+
// DMs arrive on uncached channels
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export { connectDiscord };
|
|
192
|
+
//# sourceMappingURL=discord.js.map
|
|
193
|
+
//# sourceMappingURL=discord.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/channels/roster.ts","../../src/channels/types.ts","../../src/channels/discord.ts"],"names":[],"mappings":"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","file":"discord.js","sourcesContent":["// ─────────────────────────────────────────────────────────────\n// src/channels/roster.ts — who the Will knows on a platform, and where\n// ─────────────────────────────────────────────────────────────\n//\n// The mind knows *entities*; a platform knows user ids and channels. The roster\n// is the durable seam between them: for each entity the Will has met on a\n// channel it records how to reach them again — so a *proactive* utterance\n// (`message.to` from the mind's own initiative) can find its person after a\n// restart, not just within one session.\n//\n// It persists as a small JSON file next to the PMA artifact. Writes are\n// throttled (the file is advisory routing state, not cognition — losing the\n// last few seconds costs a fallback delivery, never memory).\n// ─────────────────────────────────────────────────────────────\n\nimport { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'\nimport { dirname } from 'node:path'\n\n/** How to reach one entity on the platform. */\nexport interface RosterEntry {\n /** The mind-side entity id, e.g. 'discord:80351110224678912'. */\n entityId: string\n /** The platform-side user id. */\n userId: string\n /** Last display name seen (advisory — the *learned* name lives in the mind). */\n displayName?: string\n /** DM channel id, once one is known. */\n dmChannelId?: string\n /** Last shared (guild) channel this entity spoke in. */\n lastChannelId?: string\n /** Epoch ms of the last message seen from them. */\n lastSeenAt: number\n}\n\nconst FLUSH_MS = 2_000\n\nexport class ChannelRoster {\n private entries = new Map<string, RosterEntry>()\n private dirty = false\n private timer: ReturnType<typeof setTimeout> | null = null\n\n constructor( private readonly path: string ) {\n if( existsSync( path ) ){\n try {\n const raw = JSON.parse( readFileSync( path, 'utf8' ) ) as RosterEntry[]\n for( const e of Array.isArray( raw ) ? raw : [] ) this.entries.set( e.entityId, e )\n }\n catch { /* a corrupt roster is not worth failing a boot over — start fresh */ }\n }\n }\n\n /** Upsert what we just learned about an entity; schedules a throttled flush. */\n record( update: { entityId: string; userId: string } & Partial<Omit<RosterEntry, 'entityId' | 'userId'>> ): RosterEntry {\n const prev = this.entries.get( update.entityId )\n const next: RosterEntry = {\n lastSeenAt: Date.now(),\n ...prev,\n ...Object.fromEntries( Object.entries( update ).filter( ( [ , v ] ) => v !== undefined ) ) as typeof update,\n }\n this.entries.set( next.entityId, next )\n this.dirty = true\n if( !this.timer ){\n this.timer = setTimeout( () => { this.timer = null; this.flush() }, FLUSH_MS )\n this.timer.unref?.()\n }\n return next\n }\n\n resolve( entityId: string ): RosterEntry | undefined {\n return this.entries.get( entityId )\n }\n\n all(): RosterEntry[] {\n return [ ...this.entries.values() ]\n }\n\n /** Write to disk now (no-op when clean). Called by bridges on close. */\n flush(): void {\n if( !this.dirty ) return\n try {\n mkdirSync( dirname( this.path ), { recursive: true } )\n writeFileSync( this.path, JSON.stringify( this.all(), null, 2 ) )\n this.dirty = false\n }\n catch { /* advisory state — never take the mind down over it */ }\n }\n}\n","// ─────────────────────────────────────────────────────────────\n// src/channels/types.ts — the channel-bridge contract\n// ─────────────────────────────────────────────────────────────\n//\n// A channel bridge puts a Will *in a place where people already are* (Discord,\n// Telegram, Slack, …). It is a host surface, not a cognition surface: it turns\n// platform messages into `perceive` stimuli and delivers the Will's projected\n// utterances back — nothing more. The paradigm survives the crossing:\n//\n// • every platform user is an entity the Will comes to know (`from`),\n// with a *learned* name (`speaker`) — never a placeholder;\n// • every platform channel/DM is a conversation thread (`thread`);\n// • the Will decides when to speak. Silence is a valid outcome, so a\n// bridge never fabricates a reply and never times a message out into\n// an error.\n//\n// Bridges live at the same altitude as the MCP/HTTP hosts (src/mcp, src/serve):\n// they wrap the SDK facade, not the stem.\n// ─────────────────────────────────────────────────────────────\n\n/** A running connection between one Will and one platform. */\nexport interface ChannelBridge {\n /** Platform kind, e.g. 'discord'. */\n readonly kind: string\n /** Connect and start relaying. Resolves once the bridge is live. */\n start(): Promise<void>\n /** Disconnect and release resources. Idempotent. */\n close(): Promise<void>\n}\n\n/** Split a message into platform-sized chunks on natural boundaries. */\nexport function chunkText( text: string, max: number ): string[] {\n if( text.length <= max ) return [ text ]\n const chunks: string[] = []\n let rest = text\n while( rest.length > max ){\n // Prefer a paragraph break, then a line break, then a space — else hard-cut.\n const window = rest.slice( 0, max )\n const cut = Math.max( window.lastIndexOf( '\\n\\n' ), window.lastIndexOf( '\\n' ), window.lastIndexOf( ' ' ) )\n const at = cut > max * 0.5 ? cut : max\n chunks.push( rest.slice( 0, at ).trimEnd() )\n rest = rest.slice( at ).trimStart()\n }\n if( rest ) chunks.push( rest )\n return chunks\n}\n","// ─────────────────────────────────────────────────────────────\n// src/channels/discord.ts — a Will present in a Discord server\n// ─────────────────────────────────────────────────────────────\n//\n// The bridge relays both directions of the paradigm and nothing else:\n//\n// inbound guild/DM message → will.perceive({ from, speaker, text, thread })\n// — every author is `discord:<userId>` (stable across guilds), the\n// display name is *learned* by the mind, and each Discord channel\n// is its own conversation thread.\n// outbound will.on('message') → the addressee's last shared channel, else\n// their DM, else the home channel. Proactive utterances (the mind\n// speaking first) route the same way — that is the point.\n//\n// The Will decides when to speak. There is no command prefix and no forced\n// reply: unaddressed chatter is perceived (salience-scored by audition) and\n// silence is a valid outcome. `mentionOnly` narrows perception for busy\n// servers; it does not turn the bridge into an ask() surface.\n//\n// discord.js is imported lazily inside `createDiscordClient` — tests (and any\n// host that brings its own client) inject `client`, and the structural\n// `DiscordLikeClient` type keeps the dependency out of the type graph.\n// ─────────────────────────────────────────────────────────────\n\nimport type { Will, WillMessage } from '#sdk/will'\nimport { ChannelRoster } from '#channels/roster'\nimport { chunkText, type ChannelBridge } from '#channels/types'\n\nconst DISCORD_MESSAGE_LIMIT = 2000\n\n// ── The slice of discord.js the bridge actually uses (structural) ───────────\n\nexport interface DiscordLikeChannel {\n send( content: string ): Promise<unknown>\n sendTyping?(): Promise<unknown>\n}\n\nexport interface DiscordLikeMessage {\n content: string\n cleanContent?: string\n channelId: string\n guildId?: string | null\n author: { id: string; bot?: boolean; username?: string; displayName?: string }\n member?: { displayName?: string } | null\n mentions?: { has( userId: string ): boolean }\n channel: DiscordLikeChannel\n}\n\nexport interface DiscordLikeClient {\n user: { id: string; setPresence?( p: unknown ): void } | null\n /** discord.js ≥14.22; polled so we needn't subscribe to the deprecated `ready`. */\n isReady?(): boolean\n on( event: 'messageCreate', fn: ( m: DiscordLikeMessage ) => void ): unknown\n once( event: string, fn: () => void ): unknown\n login( token: string ): Promise<unknown>\n destroy(): Promise<unknown> | void\n channels: { fetch( id: string ): Promise<unknown> }\n users: { fetch( id: string ): Promise<{ send( content: string ): Promise<unknown> }> }\n}\n\n// ── Options ──────────────────────────────────────────────────────────────────\n\nexport interface DiscordBridgeOptions {\n /** Bot token (Discord developer portal). Unused when `client` is injected pre-logged-in. */\n token?: string\n /** Channel ids the Will inhabits. Unset = every channel it can see. */\n channels?: string[]\n /** Perceive guild messages only when the Will is @mentioned (DMs always perceived). */\n mentionOnly?: boolean\n /** Fallback channel for utterances with no reachable addressee. */\n homeChannelId?: string\n /** Roster path (default: ./.will/<willId>.discord.json). */\n rosterPath?: string\n /** Test / power-user seam: bring your own client; discord.js is never imported. */\n client?: DiscordLikeClient\n log?: ( msg: string ) => void\n}\n\n// ── The bridge ───────────────────────────────────────────────────────────────\n\n/**\n * Connect a Will to Discord. Resolves once the bridge is live (logged in and\n * relaying). Close it via the returned `ChannelBridge.close()` — the Will\n * itself is not stopped; it simply loses this surface.\n */\nexport async function connectDiscord( will: Will, opts: DiscordBridgeOptions ): Promise<ChannelBridge> {\n const log = opts.log ?? ( ( m: string ) => console.error( `[will:discord] ${ m }` ) )\n const roster = new ChannelRoster( opts.rosterPath ?? `.will/${ will.id }.discord.json` )\n const allowed = opts.channels?.length ? new Set( opts.channels ) : null\n\n const client = opts.client ?? await createDiscordClient()\n\n /** The most recently active allowed channel — last-resort proactive target. */\n let lastActiveChannelId: string | null = opts.homeChannelId ?? null\n\n // ── inbound: platform message → stimulus ──────────────────────────────────\n client.on( 'messageCreate', message => { void onMessage( message ) } )\n\n async function onMessage( message: DiscordLikeMessage ): Promise<void> {\n const self = client.user\n if( !self || message.author.id === self.id || message.author.bot ) return\n\n const isDM = !message.guildId\n if( !isDM && allowed && !allowed.has( message.channelId ) ) return\n\n const addressed = isDM || ( message.mentions?.has( self.id ) ?? false )\n if( opts.mentionOnly && !addressed ) return\n\n const entityId = `discord:${ message.author.id }`\n const speaker = message.member?.displayName ?? message.author.displayName ?? message.author.username\n\n roster.record( {\n entityId,\n userId: message.author.id,\n ...( speaker ? { displayName: speaker } : {} ),\n ...( isDM ? { dmChannelId: message.channelId } : { lastChannelId: message.channelId } ),\n } )\n if( !isDM ) lastActiveChannelId = message.channelId\n\n // Being addressed is the one moment a presence cue is honest — the mind\n // may still choose silence, and typing expires on its own.\n if( addressed ) await message.channel.sendTyping?.().catch( () => {} )\n\n const text = message.cleanContent || message.content\n if( !text.trim() ) return\n\n await will.perceive( {\n text,\n from: entityId,\n thread: `discord:${ message.channelId }`,\n ...( speaker ? { speaker } : {} ),\n } )\n }\n\n // ── outbound: projected utterance → the addressee ─────────────────────────\n // The facade has no off(); the bridge gates its handler on `closed` instead.\n let closed = false\n will.on( 'message', ( m: WillMessage ) => { if( !closed ) void deliver( m ) } )\n\n async function deliver( m: WillMessage ): Promise<void> {\n const peer = m.to ? roster.resolve( m.to ) : undefined\n const chunks = chunkText( m.content, DISCORD_MESSAGE_LIMIT )\n\n // Preference order: where we last shared a room → their DM → home channel.\n const channelIds = [ peer?.lastChannelId, peer?.dmChannelId, opts.homeChannelId ?? undefined, lastActiveChannelId ?? undefined ]\n for( const id of channelIds ){\n if( !id ) continue\n try {\n const channel = await client.channels.fetch( id ) as DiscordLikeChannel | null\n if( !channel?.send ) continue\n for( const chunk of chunks ) await channel.send( chunk )\n return\n }\n catch { /* try the next route */ }\n }\n if( peer ){\n try {\n const user = await client.users.fetch( peer.userId )\n for( const chunk of chunks ) await user.send( chunk )\n return\n }\n catch { /* fall through */ }\n }\n log( `no route for utterance to '${ m.to }' — dropped (${ m.content.length } chars)` )\n }\n\n // ── lifecycle ──────────────────────────────────────────────────────────────\n const bridge: ChannelBridge = {\n kind: 'discord',\n async start(): Promise<void> {\n if( !client.user ){\n // discord.js ≥14.22 renamed `ready` → `clientReady`. Subscribing to the\n // old name is what triggers its DeprecationWarning, so we take the new\n // name and poll `isReady()` for older builds rather than listening.\n const ready = new Promise<void>( resolve => {\n let poll: ReturnType<typeof setInterval> | null = null\n const done = (): void => { if( poll ) clearInterval( poll ); resolve() }\n client.once( 'clientReady', done )\n poll = setInterval( () => { if( client.isReady?.() ) done() }, 100 )\n poll.unref?.()\n } )\n await client.login( opts.token ?? '' )\n await ready\n }\n log( `${ will.name } is present on Discord as user ${ client.user?.id }` )\n },\n async close(): Promise<void> {\n if( closed ) return\n closed = true\n roster.flush()\n await Promise.resolve( client.destroy() ).catch( () => {} )\n },\n }\n return bridge\n}\n\n/** Build a real discord.js client (lazy import keeps it out of non-Discord hosts). */\nasync function createDiscordClient(): Promise<DiscordLikeClient> {\n let mod: typeof import( 'discord.js' )\n try { mod = await import( 'discord.js' ) }\n catch {\n throw new Error( 'discord.js is not installed (it is an optionalDependency) — run `bun add discord.js` / `npm i discord.js` and retry.' )\n }\n const { Client, GatewayIntentBits, Partials } = mod\n return new Client( {\n intents: [\n GatewayIntentBits.Guilds,\n GatewayIntentBits.GuildMessages,\n GatewayIntentBits.MessageContent,\n GatewayIntentBits.DirectMessages,\n ],\n partials: [ Partials.Channel ], // DMs arrive on uncached channels\n } ) as unknown as DiscordLikeClient\n}\n"]}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { W as Will } from '../will-BDq-TMQr.js';
|
|
2
|
+
import { C as ChannelBridge } from '../types-E9-HV-SW.js';
|
|
3
|
+
|
|
4
|
+
interface WaLikeMessage {
|
|
5
|
+
key: {
|
|
6
|
+
remoteJid?: string | null;
|
|
7
|
+
fromMe?: boolean | null;
|
|
8
|
+
/** Sender jid inside a group (absent in DMs). */
|
|
9
|
+
participant?: string | null;
|
|
10
|
+
};
|
|
11
|
+
/** Sender's display ("push") name. */
|
|
12
|
+
pushName?: string | null;
|
|
13
|
+
messageStubType?: number | null;
|
|
14
|
+
message?: {
|
|
15
|
+
conversation?: string | null;
|
|
16
|
+
extendedTextMessage?: {
|
|
17
|
+
text?: string | null;
|
|
18
|
+
contextInfo?: {
|
|
19
|
+
mentionedJid?: string[] | null;
|
|
20
|
+
} | null;
|
|
21
|
+
} | null;
|
|
22
|
+
imageMessage?: {
|
|
23
|
+
caption?: string | null;
|
|
24
|
+
} | null;
|
|
25
|
+
videoMessage?: {
|
|
26
|
+
caption?: string | null;
|
|
27
|
+
} | null;
|
|
28
|
+
} | null;
|
|
29
|
+
}
|
|
30
|
+
interface WaLikeSocket {
|
|
31
|
+
/** The paired account, once connected. id like '4915…:12@s.whatsapp.net'. */
|
|
32
|
+
user?: {
|
|
33
|
+
id: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
} | null;
|
|
36
|
+
ev: {
|
|
37
|
+
on(event: 'messages.upsert', fn: (u: {
|
|
38
|
+
messages: WaLikeMessage[];
|
|
39
|
+
type?: string;
|
|
40
|
+
}) => void): unknown;
|
|
41
|
+
};
|
|
42
|
+
sendMessage(jid: string, content: {
|
|
43
|
+
text: string;
|
|
44
|
+
}): Promise<unknown>;
|
|
45
|
+
sendPresenceUpdate?(state: 'composing' | 'paused', jid?: string): Promise<unknown>;
|
|
46
|
+
/** Tear the connection down (does not unlink the device). */
|
|
47
|
+
end?(err?: Error): void;
|
|
48
|
+
}
|
|
49
|
+
interface WhatsAppBridgeOptions {
|
|
50
|
+
/** Chat jids the Will inhabits (groups `…@g.us`, DMs `…@s.whatsapp.net`). Unset = every chat. */
|
|
51
|
+
chats?: string[];
|
|
52
|
+
/** Perceive group messages only when the Will is @mentioned (DMs always perceived). */
|
|
53
|
+
mentionOnly?: boolean;
|
|
54
|
+
/** Fallback chat jid for utterances with no reachable addressee. */
|
|
55
|
+
homeChatId?: string;
|
|
56
|
+
/** Linked-device credentials dir (default: ./.will/<willId>.wa-auth). QR pairs on first run. */
|
|
57
|
+
authPath?: string;
|
|
58
|
+
/** Roster path (default: ./.will/<willId>.whatsapp.json). */
|
|
59
|
+
rosterPath?: string;
|
|
60
|
+
/** Test / power-user seam: bring your own socket; Baileys is never imported. */
|
|
61
|
+
socket?: WaLikeSocket;
|
|
62
|
+
log?: (msg: string) => void;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Connect a Will to WhatsApp over the linked-device protocol. First run prints
|
|
66
|
+
* a QR to pair (Settings → Linked devices → Link a device); credentials persist
|
|
67
|
+
* in `authPath` so later runs reconnect silently. Close via the returned
|
|
68
|
+
* `ChannelBridge.close()` — the Will keeps ticking; it only loses this surface.
|
|
69
|
+
*/
|
|
70
|
+
declare function connectWhatsApp(will: Will, opts?: WhatsAppBridgeOptions): Promise<ChannelBridge>;
|
|
71
|
+
|
|
72
|
+
export { type WaLikeMessage, type WaLikeSocket, type WhatsAppBridgeOptions, connectWhatsApp };
|