wolli 0.0.2 → 0.0.4
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 +31 -21
- package/built-in/plugins/discord/README.md +156 -0
- package/built-in/plugins/discord/discord-chat.ts +50 -0
- package/built-in/plugins/discord/index.ts +234 -0
- package/built-in/plugins/discord/package.json +16 -0
- package/built-in/plugins/scheduler/cron.ts +131 -0
- package/built-in/plugins/scheduler/index.ts +240 -0
- package/{plugins → built-in/plugins}/scheduler/package.json +3 -2
- package/built-in/plugins/scheduler/scheduler-due.ts +22 -0
- package/built-in/plugins/telegram/README.md +153 -0
- package/built-in/plugins/telegram/index.ts +349 -0
- package/{plugins → built-in/plugins}/telegram/package.json +2 -2
- package/built-in/plugins/telegram/telegram-chat.ts +93 -0
- package/built-in/skills/.gitkeep +0 -0
- package/dist/cli.js +5739 -5848
- package/docs/hooks.md +103 -0
- package/docs/index.md +10 -8
- package/docs/integrations.md +78 -663
- package/docs/introduction.md +95 -0
- package/docs/plugins.md +43 -36
- package/docs/providers.md +63 -0
- package/docs/sdk.md +42 -47
- package/docs/skills.md +14 -0
- package/docs/tools.md +81 -0
- package/docs/workflows.md +170 -0
- package/package.json +2 -2
- package/docs/extensions.md +0 -2331
- package/plugins/scheduler/index.ts +0 -244
- package/plugins/scheduler/scheduler-chat.ts +0 -164
- package/plugins/telegram/index.ts +0 -311
- package/plugins/telegram/telegram-chat.ts +0 -169
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
# Wolli
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Create agents that grow around a purpose.**
|
|
6
6
|
|
|
7
7
|
[](LICENSE)
|
|
8
8
|
[](https://www.npmjs.com/package/wolli)
|
|
@@ -16,10 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
> **What is my purpose?**
|
|
18
18
|
|
|
19
|
-
Wolli lets you create
|
|
20
|
-
principle of an agent's life: it remembers across sessions, runs on a schedule,
|
|
21
|
-
acts on events, and extends itself to do its job better over time, writing its
|
|
22
|
-
own skills, integrations, and extensions as the work demands.
|
|
19
|
+
Wolli lets you create agents that grow around a purpose. Each agent remembers across sessions, runs on schedules, reacts to events, and extends itself over time by writing the skills, integrations, and workflows it needs to do its job better.
|
|
23
20
|
|
|
24
21
|
## Install
|
|
25
22
|
|
|
@@ -29,16 +26,27 @@ wolli
|
|
|
29
26
|
```
|
|
30
27
|
|
|
31
28
|
The first run sets up your provider and creates your first agent. A new agent
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
state live under `~/.wolli`.
|
|
29
|
+
opens by interviewing you to work out its purpose, then writes its own
|
|
30
|
+
`SOUL.md` — its first line becomes the agent's description everywhere. Agents
|
|
31
|
+
and state live under `~/.wolli`.
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
Agents
|
|
35
|
+
|
|
36
|
+
→ inbox Triage my email each morning, draft replies to the routine ones, flag what needs me.
|
|
37
|
+
scout Watch the repos and deps we ship; when a release or CVE needs action, open an issue and ping me.
|
|
38
|
+
ledger Track project spend across providers, reconcile invoices weekly, warn me before a budget tips over.
|
|
39
|
+
|
|
40
|
+
↑/↓ browse · enter chat · tab details · type to search commands · ctrl+c quit
|
|
41
|
+
```
|
|
35
42
|
|
|
36
43
|
## How it works
|
|
37
44
|
|
|
38
|
-
- **Purpose-built.**
|
|
39
|
-
|
|
45
|
+
- **Purpose-built.** The agent works its purpose out with you in its first
|
|
46
|
+
conversation and records it as the first line of its `SOUL.md`. It decides what
|
|
47
|
+
the agent stores, when it speaks up, and what it does unattended.
|
|
40
48
|
- **Self-extending.** The agent builds itself out for its purpose. It curates its
|
|
41
|
-
own memory and authors and installs its own skills, tools, and
|
|
49
|
+
own memory and authors and installs its own skills, tools, workflows, and integrations; they
|
|
42
50
|
live in its home and load on reload. The agent grows more capable at its job
|
|
43
51
|
instead of staying a fixed tool.
|
|
44
52
|
- **Persistent.** Sessions are an append-only JSONL tree, the agent's lifetime
|
|
@@ -49,12 +57,12 @@ state live under `~/.wolli`.
|
|
|
49
57
|
|
|
50
58
|
| File | Holds |
|
|
51
59
|
| --- | --- |
|
|
52
|
-
| `SOUL.md` | Identity, authored
|
|
60
|
+
| `SOUL.md` | Identity, authored by the agent; the first line is its purpose. |
|
|
53
61
|
| `MEMORY.md` | Durable notes the agent keeps. |
|
|
54
62
|
| `USER.md` | Facts about its human. |
|
|
55
63
|
|
|
56
64
|
- **Always on, locally.** A per-agent daemon supervised by launchd (macOS) or
|
|
57
|
-
systemd (
|
|
65
|
+
systemd (Linux) runs the agent on schedules and events while your machine is on.
|
|
58
66
|
- **Sandboxed.** The agent runs in a sandbox by default: `srt` (Apple Seatbelt /
|
|
59
67
|
bubblewrap), or optional Docker. Reaching your real machine is an explicit,
|
|
60
68
|
approval-gated escalation.
|
|
@@ -62,10 +70,10 @@ state live under `~/.wolli`.
|
|
|
62
70
|
|
|
63
71
|
## Lifecycle
|
|
64
72
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
An agent is live from the moment it is created: its daemon is installed as an
|
|
74
|
+
OS service at creation and runs on schedules and events from birth. While its
|
|
75
|
+
`SOUL.md` is empty it interviews you to work out its purpose, then authors
|
|
76
|
+
`SOUL.md` itself.
|
|
69
77
|
|
|
70
78
|
`wolli delete <name>` removes an agent and its state.
|
|
71
79
|
|
|
@@ -76,7 +84,10 @@ removes them with it:
|
|
|
76
84
|
|
|
77
85
|
| Type | What it adds |
|
|
78
86
|
| --- | --- |
|
|
79
|
-
| **
|
|
87
|
+
| **Integrations** | Transports that connect external services and message channels. |
|
|
88
|
+
| **Workflows** | Route events into sessions and automate the agent. |
|
|
89
|
+
| **Tools** | Typed actions the model calls during a turn. |
|
|
90
|
+
| **Providers** | Model providers beyond the built-in catalog. |
|
|
80
91
|
| **Skills** | The Agent Skills standard. |
|
|
81
92
|
| **Prompt templates** | `/name` slash commands. |
|
|
82
93
|
| **Themes** | TUI appearance. |
|
|
@@ -89,7 +100,7 @@ Two integrations ship bundled: **Telegram** (bidirectional chat) and a
|
|
|
89
100
|
| Command | Action |
|
|
90
101
|
| --- | --- |
|
|
91
102
|
| `wolli` | Set up on first run; otherwise pick an agent and open it |
|
|
92
|
-
| `wolli new <name>` | Create
|
|
103
|
+
| `wolli new <name>` | Create a new agent and start chatting with it |
|
|
93
104
|
| `wolli <agent>` | Open a specific agent interactively |
|
|
94
105
|
| `wolli <agent> "msg" --print` | One-shot, non-interactive reply |
|
|
95
106
|
| `wolli list` | List agents |
|
|
@@ -100,8 +111,7 @@ Two integrations ship bundled: **Telegram** (bidirectional chat) and a
|
|
|
100
111
|
## Documentation
|
|
101
112
|
|
|
102
113
|
Full documentation is in [`packages/wolli/docs`](packages/wolli/docs/index.md):
|
|
103
|
-
|
|
104
|
-
The integration/extension split is covered in [INTEGRATION.md](INTEGRATION.md).
|
|
114
|
+
workflows, integrations, tools, providers, hooks, skills, prompt templates, themes, plugins, and the SDK.
|
|
105
115
|
|
|
106
116
|
## Roadmap
|
|
107
117
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Discord
|
|
2
|
+
|
|
3
|
+
Connect a wolli agent to Discord. The bot runs over Discord's gateway WebSocket,
|
|
4
|
+
gives each channel and DM its own wolli session, and replies in place. Setup is a
|
|
5
|
+
few clicks in the Discord Developer Portal plus a single token prompt.
|
|
6
|
+
|
|
7
|
+
## How the bot behaves
|
|
8
|
+
|
|
9
|
+
Before setup, here's the part most people want to know: this bot answers
|
|
10
|
+
**everything it can read**. There is no `@mention` gate.
|
|
11
|
+
|
|
12
|
+
| Context | Behavior |
|
|
13
|
+
|---------|----------|
|
|
14
|
+
| **DMs** | Responds to every non-empty message. |
|
|
15
|
+
| **Server channels** | Responds to every non-empty message in any channel it can read. No `@mention` required — unlike some Discord agents, the bot replies to plain messages. Scope where it listens with `allowedChannelIds`. |
|
|
16
|
+
| **Other bots / itself** | Ignored. The bot skips its own messages and messages from any other bot, so it can't loop. |
|
|
17
|
+
| **Empty / media-only messages** | Skipped (there is no inbound media handling). |
|
|
18
|
+
|
|
19
|
+
While a turn runs, the bot shows Discord's **typing…** indicator in the channel and
|
|
20
|
+
keeps it alive until the reply lands. Replies are chunked at **2000 characters**
|
|
21
|
+
(Discord's per-message limit); Discord renders markdown natively, so formatting in
|
|
22
|
+
the agent's output comes through as-is.
|
|
23
|
+
|
|
24
|
+
If a new message arrives while the agent is mid-reply, it is **queued as a
|
|
25
|
+
follow-up** and answered after the current turn finishes — it does not interrupt the
|
|
26
|
+
in-flight response.
|
|
27
|
+
|
|
28
|
+
## Session model
|
|
29
|
+
|
|
30
|
+
Each channel and DM gets its **own wolli session**, bound by a `discord:channel`
|
|
31
|
+
tag:
|
|
32
|
+
|
|
33
|
+
- **Inbound** — an incoming message is routed to the session tagged for its channel.
|
|
34
|
+
If none exists yet, one is created and tagged on the spot. Histories never bleed
|
|
35
|
+
across channels, and two channels can run in parallel.
|
|
36
|
+
- **Outbound** — the reply rides the producing session's tag, so the answer always
|
|
37
|
+
returns to the channel that started the turn — not to whoever messaged most
|
|
38
|
+
recently.
|
|
39
|
+
|
|
40
|
+
## Setup
|
|
41
|
+
|
|
42
|
+
### 1. Create a Discord application
|
|
43
|
+
|
|
44
|
+
Open the [Discord Developer Portal](https://discord.com/developers/applications) and
|
|
45
|
+
click **New Application**. Name it (e.g. "Wolli") and accept the terms.
|
|
46
|
+
|
|
47
|
+
### 2. Enable the Message Content Intent
|
|
48
|
+
|
|
49
|
+
This is the critical step. Open **Bot** in the sidebar, scroll to **Privileged
|
|
50
|
+
Gateway Intents**, toggle **Message Content Intent** to **ON**, and **Save Changes**.
|
|
51
|
+
|
|
52
|
+
Without it, the bot still receives message events but the message text arrives
|
|
53
|
+
**empty**, so it can never reply. This is the single privileged intent the bot
|
|
54
|
+
needs — you do **not** need Server Members or Presence.
|
|
55
|
+
|
|
56
|
+
### 3. Reset and copy the bot token
|
|
57
|
+
|
|
58
|
+
On the same **Bot** page, click **Reset Token**, complete 2FA if prompted, and copy
|
|
59
|
+
the token. It is shown only once — if you lose it, reset and generate a new one. Keep
|
|
60
|
+
it secret.
|
|
61
|
+
|
|
62
|
+
### 4. Invite the bot
|
|
63
|
+
|
|
64
|
+
Open **OAuth2 → URL Generator** and select:
|
|
65
|
+
|
|
66
|
+
- **Scopes:** `bot`
|
|
67
|
+
- **Bot Permissions:** **View Channels**, **Send Messages**, **Read Message History**
|
|
68
|
+
|
|
69
|
+
Copy the generated URL at the bottom, open it, pick a server you administer, and
|
|
70
|
+
authorize. Only the `bot` scope is required — the `applications.commands` scope is
|
|
71
|
+
not needed.
|
|
72
|
+
|
|
73
|
+
### 5. Install and onboard in wolli
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
wolli <agent> plugins install ./built-in/plugins/discord
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
In an interactive terminal this runs onboarding immediately: it prints the connect
|
|
80
|
+
guide, then prompts you to **paste the bot token**. Paste the token from step 3.
|
|
81
|
+
Wolli verifies it with a live `GET /users/@me` call and stores it. That single token
|
|
82
|
+
is the only value you enter.
|
|
83
|
+
|
|
84
|
+
If you installed non-interactively, onboard later with:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
wolli <agent> plugins configure discord
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### 6. Restart the agent
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
wolli restart <agent>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This starts the gateway producer that connects to Discord. After onboarding a fresh
|
|
97
|
+
integration, restart the agent once so the bot comes online and begins responding.
|
|
98
|
+
|
|
99
|
+
## Configuration reference
|
|
100
|
+
|
|
101
|
+
Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under
|
|
102
|
+
`discord.default`:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"discord": {
|
|
107
|
+
"default": {
|
|
108
|
+
"botToken": "...",
|
|
109
|
+
"allowedChannelIds": ["123456789012345678"]
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
| Field | Required | Default | Purpose |
|
|
116
|
+
|-------|----------|---------|---------|
|
|
117
|
+
| `botToken` | Yes | — | The bot token. Onboarding stores it raw; a `$ENV` / `!cmd` reference placed here by hand also resolves on read. |
|
|
118
|
+
| `allowedChannelIds` | No | *(any channel)* | Allowlist of channel IDs the bot will respond in. Empty or absent accepts **any** channel the bot can read (logged as a warning at startup). |
|
|
119
|
+
|
|
120
|
+
Discord IDs are snowflakes — keep them as **quoted strings** in JSON, never numbers,
|
|
121
|
+
since a 64-bit snowflake exceeds JavaScript's safe-integer range. To grab a channel
|
|
122
|
+
ID, enable **Developer Mode** in Discord (Settings → Advanced), then right-click a
|
|
123
|
+
channel and **Copy Channel ID**.
|
|
124
|
+
|
|
125
|
+
`allowedChannelIds` is not asked during onboarding — edit `integrations.json` to set
|
|
126
|
+
it.
|
|
127
|
+
|
|
128
|
+
## Not supported
|
|
129
|
+
|
|
130
|
+
The bot is deliberately focused on text chat. It does not provide:
|
|
131
|
+
|
|
132
|
+
- Mention-gating — it answers every readable message, not only `@mentions`.
|
|
133
|
+
- User, role, or guild allowlists — channel-level `allowedChannelIds` is the only
|
|
134
|
+
scope control.
|
|
135
|
+
- Threads or auto-threading.
|
|
136
|
+
- Reactions.
|
|
137
|
+
- Native Discord slash commands.
|
|
138
|
+
- Inbound or outbound media, attachments, or voice.
|
|
139
|
+
|
|
140
|
+
## Troubleshooting
|
|
141
|
+
|
|
142
|
+
| Symptom | Fix |
|
|
143
|
+
|---------|-----|
|
|
144
|
+
| Bot is online but never replies | Enable the **Message Content Intent** (step 2). Without it the message text arrives empty and there is nothing to answer. |
|
|
145
|
+
| `Disallowed intents` on connect | Same cause: the Message Content Intent is not enabled in the Developer Portal. |
|
|
146
|
+
| No reply right after the first install/onboard | Restart the agent (`wolli restart <agent>`) so the gateway producer starts. |
|
|
147
|
+
| Silence in one specific channel | The bot is missing **View Channels** / **Send Messages** there, or the channel isn't in `allowedChannelIds`. Try a DM to isolate. |
|
|
148
|
+
|
|
149
|
+
## Security
|
|
150
|
+
|
|
151
|
+
- `allowedChannelIds` is the only access gate today. With it empty, **any** channel
|
|
152
|
+
the bot can read is accepted — set it to lock the bot to known channels.
|
|
153
|
+
- There is no per-user or per-role gate. Anyone who can post in an allowed channel
|
|
154
|
+
can drive the agent, so keep the bot in trusted channels.
|
|
155
|
+
- The bot token grants full control of the bot account — never commit it or share
|
|
156
|
+
it. Wolli writes `integrations.json` with mode `0600`.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord chat routing, as one workflows file:
|
|
3
|
+
* - `inbound` binds each channel/DM to its own session by a `discord:channel` tag and
|
|
4
|
+
* delivers inbound text as a followUp. Discord has no channel commands, so every message
|
|
5
|
+
* routes into a session.
|
|
6
|
+
* - `typing` starts the "typing…" indicator on `agent_start`; the integration owns the
|
|
7
|
+
* keep-alive timer.
|
|
8
|
+
* - `reply` ships the turn's final assistant text back on `agent_end`, riding the
|
|
9
|
+
* producing session's `discord:channel` tag; typing stops first so a pure tool-call turn
|
|
10
|
+
* still clears the indicator.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { wolli } from "wolli";
|
|
14
|
+
import discord from "./index.ts";
|
|
15
|
+
|
|
16
|
+
// m is typed from the event schema
|
|
17
|
+
export const inbound = discord.on("message", async (m, ctx) => {
|
|
18
|
+
const channelTag = { "discord:channel": m.channelId };
|
|
19
|
+
const [match] = await ctx.agent.findSessions(channelTag);
|
|
20
|
+
const session = match
|
|
21
|
+
? await ctx.agent.openSession(match.id)
|
|
22
|
+
: await ctx.agent.createSession({
|
|
23
|
+
setup: (s) => s.appendTags(channelTag),
|
|
24
|
+
});
|
|
25
|
+
// followUp queues behind a running turn instead of interrupting it.
|
|
26
|
+
await session.sendUserMessage(m.text, { deliverAs: "followUp" });
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const typing = wolli.on("agent_start", async (_evt, ctx) => {
|
|
30
|
+
const channelId = ctx.session.getTags()["discord:channel"];
|
|
31
|
+
if (!channelId) return; // not a discord-bound session
|
|
32
|
+
await ctx.integration(discord).startTyping({ channelId });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
export const reply = wolli.on("agent_end", async (evt, ctx) => {
|
|
36
|
+
const channelId = ctx.session.getTags()["discord:channel"];
|
|
37
|
+
if (!channelId) return; // not a discord-bound session
|
|
38
|
+
// Stop the typing indicator before the empty-text early return, so a pure tool-call
|
|
39
|
+
// turn still clears it.
|
|
40
|
+
await ctx.integration(discord).stopTyping({ channelId });
|
|
41
|
+
const text = evt.messages
|
|
42
|
+
.filter((m) => m.role === "assistant")
|
|
43
|
+
.at(-1)
|
|
44
|
+
?.content.filter((c) => c.type === "text")
|
|
45
|
+
.map((c) => c.text)
|
|
46
|
+
.join("")
|
|
47
|
+
.trim();
|
|
48
|
+
if (!text) return; // a pure tool-call turn sends nothing
|
|
49
|
+
await ctx.integration(discord).sendMessage({ channelId, text });
|
|
50
|
+
});
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord transport — holds the bot token, emits a `message` event per inbound message;
|
|
3
|
+
* the routing workflows (`discord-chat.ts`, declared under `wolli.workflows`) map those
|
|
4
|
+
* onto sessions. Transport is the gateway
|
|
5
|
+
* WebSocket (discord.js `Client`); snowflake ids stay `string`. MESSAGE CONTENT is a
|
|
6
|
+
* privileged intent — enable it in the Developer Portal or `m.content` is empty. The
|
|
7
|
+
* "typing…" keep-alive timers live here, since workflow files hold no module state.
|
|
8
|
+
* See README.md for setup.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { defineIntegration, type IntegrationOnboardContext } from "wolli";
|
|
12
|
+
import { Client, Events, GatewayIntentBits, Partials, REST, Routes } from "discord.js";
|
|
13
|
+
import { Type } from "typebox";
|
|
14
|
+
|
|
15
|
+
/** Discord caps a single message at 2000 UTF-16 code units. */
|
|
16
|
+
const DISCORD_MAX_LENGTH = 2000;
|
|
17
|
+
|
|
18
|
+
/** Typing indicator refresh interval — Discord's typing state lasts ~10s, so refresh a little ahead. */
|
|
19
|
+
const TYPING_INTERVAL_MS = 8000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Live "typing…" keep-alive timers, keyed by channelId. Per-channel so parallel channels
|
|
23
|
+
* never stomp each other; cleared by `stopTyping` and by the producer's disposer on reload,
|
|
24
|
+
* so a reload can never orphan an interval.
|
|
25
|
+
*/
|
|
26
|
+
const typingTimers = new Map<string, ReturnType<typeof setInterval>>();
|
|
27
|
+
|
|
28
|
+
function stopTypingTimer(channelId: string): void {
|
|
29
|
+
const timer = typingTimers.get(channelId);
|
|
30
|
+
if (timer) {
|
|
31
|
+
clearInterval(timer);
|
|
32
|
+
typingTimers.delete(channelId);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function clearAllTypingTimers(): void {
|
|
37
|
+
for (const timer of typingTimers.values()) clearInterval(timer);
|
|
38
|
+
typingTimers.clear();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ONBOARD_GUIDE = [
|
|
42
|
+
"## Connect Discord",
|
|
43
|
+
"",
|
|
44
|
+
"1. Open the [Discord Developer Portal](https://discord.com/developers/applications)",
|
|
45
|
+
" and create a **New Application**.",
|
|
46
|
+
"2. Go to **Bot**, then enable the **MESSAGE CONTENT INTENT** toggle (privileged —",
|
|
47
|
+
" without it the bot receives empty message text).",
|
|
48
|
+
"3. On the **Bot** page, **Reset Token** and copy the token.",
|
|
49
|
+
"4. Go to **OAuth2 → URL Generator**, select the `bot` scope, copy the generated URL,",
|
|
50
|
+
" open it, and invite the bot to your server.",
|
|
51
|
+
"5. Paste the token on the next screen.",
|
|
52
|
+
"",
|
|
53
|
+
"Wolli verifies the token and stores it for this agent.",
|
|
54
|
+
].join("\n");
|
|
55
|
+
|
|
56
|
+
interface DiscordAccount {
|
|
57
|
+
botToken: string;
|
|
58
|
+
allowedChannelIds?: string[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One cached `REST` client per token for the request/response actions. */
|
|
62
|
+
const rests = new Map<string, REST>();
|
|
63
|
+
function getRest(token: string): REST {
|
|
64
|
+
let rest = rests.get(token);
|
|
65
|
+
if (!rest) {
|
|
66
|
+
rest = new REST({ version: "10" }).setToken(token);
|
|
67
|
+
rests.set(token, rest);
|
|
68
|
+
}
|
|
69
|
+
return rest;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Split into ≤2000-code-unit chunks without cutting a surrogate pair. */
|
|
73
|
+
function chunkText(text: string, max = DISCORD_MAX_LENGTH): string[] {
|
|
74
|
+
if (text.length <= max) return [text];
|
|
75
|
+
const chunks: string[] = [];
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < text.length) {
|
|
78
|
+
let end = Math.min(i + max, text.length);
|
|
79
|
+
if (end < text.length) {
|
|
80
|
+
const code = text.charCodeAt(end - 1);
|
|
81
|
+
if (code >= 0xd800 && code <= 0xdbff) end -= 1;
|
|
82
|
+
}
|
|
83
|
+
chunks.push(text.slice(i, end));
|
|
84
|
+
i = end;
|
|
85
|
+
}
|
|
86
|
+
return chunks;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Guided setup: collect the bot token, verify it via `GET /users/@me`, return it to store. */
|
|
90
|
+
async function onboard(ctx: IntegrationOnboardContext): Promise<{ botToken: string } | undefined> {
|
|
91
|
+
ctx.ui.notify(ONBOARD_GUIDE, "info");
|
|
92
|
+
|
|
93
|
+
const entered = await ctx.ui.input("Paste the bot token from the Discord Developer Portal");
|
|
94
|
+
if (entered === undefined) return undefined; // cancelled
|
|
95
|
+
const token = entered.trim();
|
|
96
|
+
if (!token) {
|
|
97
|
+
ctx.ui.notify("No token entered.", "error");
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const me = (await new REST({ version: "10" }).setToken(token).get(Routes.user())) as { username: string };
|
|
103
|
+
ctx.ui.notify(`Verified bot ${me.username}.`, "info");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
ctx.ui.notify(`Could not verify the token: ${err instanceof Error ? err.message : String(err)}`, "error");
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { botToken: token };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export default defineIntegration({
|
|
113
|
+
account: Type.Object({
|
|
114
|
+
botToken: Type.String(),
|
|
115
|
+
/** Empty/absent = allow all (logged as a warning). */
|
|
116
|
+
allowedChannelIds: Type.Optional(Type.Array(Type.String())),
|
|
117
|
+
}),
|
|
118
|
+
events: {
|
|
119
|
+
message: Type.Object({
|
|
120
|
+
channelId: Type.String(),
|
|
121
|
+
messageId: Type.String(),
|
|
122
|
+
text: Type.String(),
|
|
123
|
+
author: Type.Object({
|
|
124
|
+
id: Type.String(),
|
|
125
|
+
name: Type.Optional(Type.String()),
|
|
126
|
+
}),
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
onboard,
|
|
130
|
+
actions: {
|
|
131
|
+
sendMessage: {
|
|
132
|
+
description: "Send a text message to a channel (chunked at 2000; Discord renders markdown natively).",
|
|
133
|
+
parameters: Type.Object({
|
|
134
|
+
channelId: Type.String(),
|
|
135
|
+
text: Type.String(),
|
|
136
|
+
}),
|
|
137
|
+
execute: async (params, ctx) => {
|
|
138
|
+
const { channelId, text } = params as { channelId: string; text: string };
|
|
139
|
+
const account = ctx.account as DiscordAccount;
|
|
140
|
+
const rest = getRest(account.botToken);
|
|
141
|
+
|
|
142
|
+
const messageIds: string[] = [];
|
|
143
|
+
for (const content of chunkText(text)) {
|
|
144
|
+
const sent = (await rest.post(Routes.channelMessages(channelId), { body: { content } })) as {
|
|
145
|
+
id: string;
|
|
146
|
+
};
|
|
147
|
+
messageIds.push(sent.id);
|
|
148
|
+
}
|
|
149
|
+
return { messageIds };
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
startTyping: {
|
|
153
|
+
description: "Show the 'typing…' indicator in a channel and keep it alive on a timer until stopTyping.",
|
|
154
|
+
parameters: Type.Object({
|
|
155
|
+
channelId: Type.String(),
|
|
156
|
+
}),
|
|
157
|
+
execute: async (params, ctx) => {
|
|
158
|
+
const { channelId } = params as { channelId: string };
|
|
159
|
+
const account = ctx.account as DiscordAccount;
|
|
160
|
+
const rest = getRest(account.botToken);
|
|
161
|
+
const send = () => {
|
|
162
|
+
void rest.post(Routes.channelTyping(channelId)).catch(() => {});
|
|
163
|
+
};
|
|
164
|
+
send(); // immediate first tick; the indicator would otherwise lag by the interval
|
|
165
|
+
stopTypingTimer(channelId); // replace any existing timer for this channel
|
|
166
|
+
typingTimers.set(channelId, setInterval(send, TYPING_INTERVAL_MS));
|
|
167
|
+
return { ok: true };
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
stopTyping: {
|
|
171
|
+
description: "Stop the 'typing…' indicator in a channel.",
|
|
172
|
+
parameters: Type.Object({
|
|
173
|
+
channelId: Type.String(),
|
|
174
|
+
}),
|
|
175
|
+
execute: async (params) => {
|
|
176
|
+
const { channelId } = params as { channelId: string };
|
|
177
|
+
stopTypingTimer(channelId);
|
|
178
|
+
return { ok: true };
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
run(ctx) {
|
|
183
|
+
const account = ctx.account as DiscordAccount;
|
|
184
|
+
const { botToken, allowedChannelIds } = account;
|
|
185
|
+
const allowAll = !allowedChannelIds || allowedChannelIds.length === 0;
|
|
186
|
+
if (allowAll) {
|
|
187
|
+
console.warn("[discord] no allowedChannelIds configured — accepting messages from ANY channel.");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const client = new Client({
|
|
191
|
+
intents: [
|
|
192
|
+
GatewayIntentBits.Guilds,
|
|
193
|
+
GatewayIntentBits.GuildMessages,
|
|
194
|
+
GatewayIntentBits.MessageContent,
|
|
195
|
+
GatewayIntentBits.DirectMessages,
|
|
196
|
+
],
|
|
197
|
+
partials: [Partials.Channel], // required to receive DMs
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
client.on(Events.MessageCreate, (m) => {
|
|
201
|
+
if (m.author.id === client.user?.id) return; // skip self
|
|
202
|
+
if (m.author.bot) return; // skip other bots (loop prevention)
|
|
203
|
+
if (!allowAll && !allowedChannelIds?.includes(m.channelId)) return;
|
|
204
|
+
const text = m.content;
|
|
205
|
+
if (!text) return; // empty without the MESSAGE CONTENT intent, or media-only
|
|
206
|
+
|
|
207
|
+
ctx.emit("message", {
|
|
208
|
+
channelId: m.channelId,
|
|
209
|
+
messageId: m.id,
|
|
210
|
+
text,
|
|
211
|
+
author: { id: m.author.id, name: m.author.username },
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
client.on(Events.Error, (err) => {
|
|
216
|
+
console.error("[discord] client error:", err.message);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// Fire-and-forget: login never settles into a state we await.
|
|
220
|
+
if (!ctx.signal.aborted) {
|
|
221
|
+
void client.login(botToken).catch((err) => {
|
|
222
|
+
console.error("[discord] failed to log in:", err instanceof Error ? err.message : err);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const dispose = () => {
|
|
227
|
+
// Clear typing timers here so a reload can never orphan an interval.
|
|
228
|
+
clearAllTypingTimers();
|
|
229
|
+
void client.destroy();
|
|
230
|
+
};
|
|
231
|
+
ctx.signal.addEventListener("abort", dispose);
|
|
232
|
+
return dispose;
|
|
233
|
+
},
|
|
234
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "wolli-integration-discord",
|
|
3
|
+
"private": true,
|
|
4
|
+
"version": "1.0.0",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"wolli": {
|
|
7
|
+
"integrations": ["./index.ts"],
|
|
8
|
+
"workflows": ["./discord-chat.ts"]
|
|
9
|
+
},
|
|
10
|
+
"dependencies": {
|
|
11
|
+
"discord.js": "14.26.4"
|
|
12
|
+
},
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"wolli": "*"
|
|
15
|
+
}
|
|
16
|
+
}
|