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.
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Cron tool — lets the agent schedule its own prompts (add / list / update / remove / run)
3
+ * by driving the scheduler integration's CRUD actions. `add` snapshots the calling
4
+ * session's tags onto the job (`originTags`), so when the job fires, `scheduler-due.ts`
5
+ * wakes the session that surface belongs to and its reply returns there with no
6
+ * scheduler-side special-casing.
7
+ */
8
+
9
+ import { defineTool } from "wolli";
10
+ import { Type } from "typebox";
11
+ import scheduler from "./index.ts";
12
+
13
+ const CronParams = Type.Object({
14
+ action: Type.Union([
15
+ Type.Literal("add"),
16
+ Type.Literal("list"),
17
+ Type.Literal("update"),
18
+ Type.Literal("remove"),
19
+ Type.Literal("run"),
20
+ ]),
21
+ prompt: Type.Optional(Type.String({ description: "What to run (the woken session's first message)." })),
22
+ name: Type.Optional(Type.String({ description: "Human label for the job." })),
23
+ at: Type.Optional(Type.Number({ description: "One-shot run time, epoch ms." })),
24
+ everyMs: Type.Optional(Type.Number({ description: "Fixed interval in ms." })),
25
+ cron: Type.Optional(Type.String({ description: "Cron expression (5/6-field)." })),
26
+ tz: Type.Optional(Type.String({ description: "Timezone for the cron expression (host local if omitted)." })),
27
+ id: Type.Optional(Type.String({ description: "Job id, for update / remove / run." })),
28
+ enabled: Type.Optional(Type.Boolean({ description: "Enable or disable the job (update)." })),
29
+ });
30
+
31
+ /** The fields of a job this tool reads back from `listJobs` (the integration owns the full shape). */
32
+ interface Job {
33
+ id: string;
34
+ name?: string;
35
+ schedule: { kind: "at"; at: number } | { kind: "every"; everyMs: number } | { kind: "cron"; expr: string; tz?: string };
36
+ enabled: boolean;
37
+ nextRunAt: number;
38
+ }
39
+
40
+ /** Map the tool's flat `at`/`everyMs`/`cron` fields onto the integration's `Schedule` union. */
41
+ function buildSchedule(p: { at?: number; everyMs?: number; cron?: string; tz?: string }): Job["schedule"] | undefined {
42
+ if (p.at !== undefined) return { kind: "at", at: p.at };
43
+ if (p.everyMs !== undefined) return { kind: "every", everyMs: p.everyMs };
44
+ if (p.cron !== undefined) return { kind: "cron", expr: p.cron, tz: p.tz };
45
+ return undefined;
46
+ }
47
+
48
+ function describeSchedule(schedule: Job["schedule"]): string {
49
+ switch (schedule.kind) {
50
+ case "at":
51
+ return `at ${new Date(schedule.at).toISOString()}`;
52
+ case "every":
53
+ return `every ${schedule.everyMs}ms`;
54
+ case "cron":
55
+ return `cron "${schedule.expr}"${schedule.tz ? ` (${schedule.tz})` : ""}`;
56
+ }
57
+ }
58
+
59
+ function text(message: string, details: unknown) {
60
+ return { content: [{ type: "text" as const, text: message }], details };
61
+ }
62
+
63
+ export default defineTool({
64
+ name: "cron",
65
+ label: "Cron",
66
+ description:
67
+ "Schedule prompts to run later. Actions: add (prompt + at/everyMs/cron), list, update (id), remove (id), run (id).",
68
+ parameters: CronParams,
69
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
70
+ const sched = ctx.integration(scheduler);
71
+ try {
72
+ switch (params.action) {
73
+ case "add": {
74
+ if (!params.prompt) return text("Error: prompt is required to add a job.", { error: "prompt required" });
75
+ const schedule = buildSchedule(params);
76
+ if (!schedule) {
77
+ return text("Error: provide one of at, everyMs, or cron.", { error: "schedule required" });
78
+ }
79
+ // Snapshot the scheduling session's tags so the fired result returns to this surface.
80
+ const result = (await sched.addJob({
81
+ prompt: params.prompt,
82
+ name: params.name,
83
+ schedule,
84
+ originTags: ctx.session.getTags(),
85
+ })) as { id: string; nextRunAt: number };
86
+ return text(
87
+ `Scheduled job ${result.id} — ${describeSchedule(schedule)} — next ${new Date(result.nextRunAt).toISOString()}.`,
88
+ result,
89
+ );
90
+ }
91
+ case "list": {
92
+ const result = (await sched.listJobs({})) as { jobs: Job[] };
93
+ const body = result.jobs.length
94
+ ? result.jobs
95
+ .map((j) => {
96
+ const label = j.name ? `${j.name} ` : "";
97
+ const state = j.enabled ? `next ${new Date(j.nextRunAt).toISOString()}` : "disabled";
98
+ return `${j.id} ${label}— ${describeSchedule(j.schedule)} — ${state}`;
99
+ })
100
+ .join("\n")
101
+ : "No scheduled jobs.";
102
+ return text(body, result);
103
+ }
104
+ case "update": {
105
+ if (!params.id) return text("Error: id is required to update a job.", { error: "id required" });
106
+ const result = await sched.updateJob({
107
+ id: params.id,
108
+ prompt: params.prompt,
109
+ name: params.name,
110
+ schedule: buildSchedule(params),
111
+ enabled: params.enabled,
112
+ });
113
+ return text(`Updated job ${params.id}.`, result);
114
+ }
115
+ case "remove": {
116
+ if (!params.id) return text("Error: id is required to remove a job.", { error: "id required" });
117
+ const result = (await sched.removeJob({ id: params.id })) as { removed: boolean };
118
+ return text(result.removed ? `Removed job ${params.id}.` : `No job ${params.id}.`, result);
119
+ }
120
+ case "run": {
121
+ if (!params.id) return text("Error: id is required to run a job.", { error: "id required" });
122
+ const result = await sched.runJob({ id: params.id });
123
+ return text(`Job ${params.id} will run on the next tick.`, result);
124
+ }
125
+ }
126
+ } catch (err) {
127
+ const message = err instanceof Error ? err.message : String(err);
128
+ return text(`Error: ${message}`, { error: message });
129
+ }
130
+ },
131
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Scheduler integration — the timer half (self-contained package).
3
+ *
4
+ * This integration owns the jobs and the wake loop: it persists jobs in `ctx.store`
5
+ * (one file at `~/.wolli/agents/<name>/store/scheduler.json`), ticks a coarse timer,
6
+ * and emits a `due` event when a job's time arrives. It does not touch sessions or the
7
+ * agent; the `cron` tool (`cron.ts`) drives the CRUD actions and the `scheduler-due.ts`
8
+ * workflow, on `due`, wakes the session the job was scheduled from.
9
+ *
10
+ * Jobs are scheduled by the agent through the `cron` tool, which calls the CRUD actions
11
+ * below. The scheduler has no secret — onboarding just writes an empty `scheduler`
12
+ * account record so `run()` starts.
13
+ *
14
+ * ## Guarantees
15
+ * - At-most-once: a tick advances a job's `nextRunAt` (or disables a one-shot) and
16
+ * persists that BEFORE emitting `due`, so a crash/reload right after an emit never
17
+ * double-fires.
18
+ * - Missed runs while down: the catch-up tick on start fires each overdue job once
19
+ * (recompute-from-now), not one replay per missed interval.
20
+ */
21
+
22
+ import { randomUUID } from "node:crypto";
23
+ import { defineIntegration, type IntegrationOnboardContext, type KeyValueStore } from "wolli";
24
+ import { Cron } from "croner";
25
+ import { type Static, Type } from "typebox";
26
+
27
+ /** Default wake interval — coarse by design (a fixed tick is trivially idempotent across reloads). */
28
+ const DEFAULT_TICK_MS = 60_000;
29
+
30
+ const Schedule = Type.Union([
31
+ /** One-shot at an absolute epoch-ms instant. */
32
+ Type.Object({ kind: Type.Literal("at"), at: Type.Number() }),
33
+ /** Fixed interval; the first run is one interval after creation. */
34
+ Type.Object({ kind: Type.Literal("every"), everyMs: Type.Number() }),
35
+ /** Cron expression; `tz` omitted = host local time. */
36
+ Type.Object({ kind: Type.Literal("cron"), expr: Type.String(), tz: Type.Optional(Type.String()) }),
37
+ ]);
38
+ type Schedule = Static<typeof Schedule>;
39
+
40
+ interface Job {
41
+ id: string;
42
+ name?: string;
43
+ prompt: string;
44
+ schedule: Schedule;
45
+ enabled: boolean;
46
+ /** Tags of the session that scheduled the job; the fired result is delivered to the newest session matching these. */
47
+ originTags?: Record<string, string>;
48
+ /** Epoch ms; advanced before firing. */
49
+ nextRunAt: number;
50
+ lastRunAt?: number;
51
+ }
52
+
53
+ interface SchedulerAccount {
54
+ tickMs?: number;
55
+ }
56
+
57
+ /** Jobs live under the single store key `"jobs"`, keyed by id. */
58
+ function loadJobs(store: KeyValueStore): Record<string, Job> {
59
+ return (store.get("jobs") as Record<string, Job> | undefined) ?? {};
60
+ }
61
+ function saveJobs(store: KeyValueStore, jobs: Record<string, Job>): void {
62
+ store.set("jobs", jobs);
63
+ }
64
+
65
+ /** Next run for a schedule relative to `fromMs`; null when there is no future run. */
66
+ function computeNextRunAt(schedule: Schedule, fromMs: number): number | null {
67
+ switch (schedule.kind) {
68
+ case "at":
69
+ return schedule.at;
70
+ case "every":
71
+ return fromMs + schedule.everyMs;
72
+ case "cron":
73
+ return new Cron(schedule.expr, { timezone: schedule.tz }).nextRun(new Date(fromMs))?.getTime() ?? null;
74
+ }
75
+ }
76
+
77
+ async function onboard(ctx: IntegrationOnboardContext): Promise<Record<string, unknown>> {
78
+ ctx.ui.notify("Scheduler enabled.", "info");
79
+ return {};
80
+ }
81
+
82
+ export default defineIntegration({
83
+ account: Type.Object({
84
+ /** Wake interval in ms; defaults to 60s. */
85
+ tickMs: Type.Optional(Type.Number()),
86
+ }),
87
+ events: {
88
+ due: Type.Object({
89
+ id: Type.String(),
90
+ prompt: Type.String(),
91
+ originTags: Type.Optional(Type.Record(Type.String(), Type.String())),
92
+ name: Type.Optional(Type.String()),
93
+ }),
94
+ },
95
+ onboard,
96
+ actions: {
97
+ addJob: {
98
+ description: "Schedule a new job from a prompt and a schedule (at / every / cron).",
99
+ parameters: Type.Object({
100
+ prompt: Type.String(),
101
+ name: Type.Optional(Type.String()),
102
+ schedule: Schedule,
103
+ originTags: Type.Optional(Type.Record(Type.String(), Type.String())),
104
+ }),
105
+ execute: async (params, ctx) => {
106
+ const p = params as {
107
+ prompt: string;
108
+ name?: string;
109
+ schedule: Schedule;
110
+ originTags?: Record<string, string>;
111
+ };
112
+ const now = Date.now();
113
+ const seeded = computeNextRunAt(p.schedule, now);
114
+ const job: Job = {
115
+ id: randomUUID(),
116
+ name: p.name,
117
+ prompt: p.prompt,
118
+ schedule: p.schedule,
119
+ enabled: seeded !== null,
120
+ originTags: p.originTags,
121
+ nextRunAt: seeded ?? 0,
122
+ };
123
+ const jobs = loadJobs(ctx.store);
124
+ jobs[job.id] = job;
125
+ saveJobs(ctx.store, jobs);
126
+ return { id: job.id, nextRunAt: job.nextRunAt };
127
+ },
128
+ },
129
+ listJobs: {
130
+ description: "List all scheduled jobs.",
131
+ parameters: Type.Object({}),
132
+ execute: async (_params, ctx) => {
133
+ return { jobs: Object.values(loadJobs(ctx.store)) };
134
+ },
135
+ },
136
+ updateJob: {
137
+ description: "Update a job by id; recomputes the next run when the schedule changes.",
138
+ parameters: Type.Object({
139
+ id: Type.String(),
140
+ prompt: Type.Optional(Type.String()),
141
+ name: Type.Optional(Type.String()),
142
+ schedule: Type.Optional(Schedule),
143
+ enabled: Type.Optional(Type.Boolean()),
144
+ }),
145
+ execute: async (params, ctx) => {
146
+ const p = params as {
147
+ id: string;
148
+ prompt?: string;
149
+ name?: string;
150
+ schedule?: Schedule;
151
+ enabled?: boolean;
152
+ };
153
+ const jobs = loadJobs(ctx.store);
154
+ const job = jobs[p.id];
155
+ if (!job) throw new Error(`unknown job '${p.id}'`);
156
+
157
+ if (p.prompt !== undefined) job.prompt = p.prompt;
158
+ if (p.name !== undefined) job.name = p.name;
159
+ if (p.enabled !== undefined) job.enabled = p.enabled;
160
+ if (p.schedule !== undefined) {
161
+ job.schedule = p.schedule;
162
+ const next = computeNextRunAt(p.schedule, Date.now());
163
+ job.nextRunAt = next ?? 0;
164
+ if (next === null) job.enabled = false;
165
+ }
166
+
167
+ saveJobs(ctx.store, jobs);
168
+ return { job };
169
+ },
170
+ },
171
+ removeJob: {
172
+ description: "Delete a job by id.",
173
+ parameters: Type.Object({ id: Type.String() }),
174
+ execute: async (params, ctx) => {
175
+ const { id } = params as { id: string };
176
+ const jobs = loadJobs(ctx.store);
177
+ const removed = id in jobs;
178
+ delete jobs[id];
179
+ saveJobs(ctx.store, jobs);
180
+ return { removed };
181
+ },
182
+ },
183
+ runJob: {
184
+ description: "Run a job on the next tick (sets it due immediately).",
185
+ parameters: Type.Object({ id: Type.String() }),
186
+ execute: async (params, ctx) => {
187
+ const { id } = params as { id: string };
188
+ const jobs = loadJobs(ctx.store);
189
+ const job = jobs[id];
190
+ if (!job) throw new Error(`unknown job '${id}'`);
191
+ job.enabled = true;
192
+ job.nextRunAt = 0;
193
+ saveJobs(ctx.store, jobs);
194
+ return { id, nextRunAt: job.nextRunAt };
195
+ },
196
+ },
197
+ },
198
+ run(ctx) {
199
+ const account = ctx.account as SchedulerAccount;
200
+ const tickMs = account.tickMs ?? DEFAULT_TICK_MS;
201
+
202
+ const tick = (): void => {
203
+ const now = Date.now();
204
+ const jobs = loadJobs(ctx.store);
205
+ const due: Job[] = [];
206
+ for (const job of Object.values(jobs)) {
207
+ if (!job.enabled || job.nextRunAt > now) continue;
208
+ job.lastRunAt = now;
209
+ if (job.schedule.kind === "at") {
210
+ job.enabled = false; // one-shot
211
+ } else {
212
+ const next = computeNextRunAt(job.schedule, now);
213
+ if (next === null) job.enabled = false;
214
+ else job.nextRunAt = next;
215
+ }
216
+ due.push(job);
217
+ }
218
+ if (due.length === 0) return;
219
+
220
+ // Persist the advanced state before emitting so a crash right after an emit never re-fires.
221
+ saveJobs(ctx.store, jobs);
222
+ for (const job of due) {
223
+ ctx.emit("due", {
224
+ id: job.id,
225
+ prompt: job.prompt,
226
+ originTags: job.originTags,
227
+ name: job.name,
228
+ });
229
+ }
230
+ };
231
+
232
+ // One catch-up tick on start: each overdue job fires once (recompute-from-now), not N replays.
233
+ tick();
234
+ const timer = setInterval(tick, tickMs);
235
+
236
+ const dispose = () => clearInterval(timer);
237
+ ctx.signal.addEventListener("abort", dispose);
238
+ return dispose;
239
+ },
240
+ });
@@ -5,12 +5,13 @@
5
5
  "type": "module",
6
6
  "wolli": {
7
7
  "integrations": ["./index.ts"],
8
- "extensions": ["./scheduler-chat.ts"]
8
+ "workflows": ["./scheduler-due.ts"],
9
+ "tools": ["./cron.ts"]
9
10
  },
10
11
  "dependencies": {
11
12
  "croner": "10.0.1"
12
13
  },
13
14
  "peerDependencies": {
14
- "@opsyhq/wolli": "*"
15
+ "wolli": "*"
15
16
  }
16
17
  }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Scheduler due routing — on a `due` event, runs the job's prompt as a turn in the session
3
+ * it was scheduled from (the newest match for the job's origin tags). A telegram-tagged
4
+ * origin means telegram's own `agent_end` ships the reply back to that chat; no
5
+ * scheduler-side channel handling. If no session matches (the origin was pruned), a fresh
6
+ * one is created carrying the SAME origin tags so it stays bound to that surface — never
7
+ * an untagged session, which would deliver nowhere.
8
+ */
9
+
10
+ import scheduler from "./index.ts";
11
+
12
+ export const due = scheduler.on("due", async (job, ctx) => {
13
+ const originTags = job.originTags ?? {};
14
+ const [match] = await ctx.agent.findSessions(originTags);
15
+ const session = match
16
+ ? await ctx.agent.openSession(match.id)
17
+ : await ctx.agent.createSession({
18
+ setup: (s) => s.appendTags(originTags),
19
+ });
20
+ // followUp queues cleanly if a turn is in flight.
21
+ await session.sendUserMessage(job.prompt, { deliverAs: "followUp" });
22
+ });
@@ -0,0 +1,153 @@
1
+ # Telegram
2
+
3
+ Connect a wolli agent to Telegram. The bot runs over Telegram's long-polling API,
4
+ gives each chat its own wolli session, and replies in place. Setup is one message to
5
+ [@BotFather](https://t.me/BotFather) plus a single token prompt — no public URL or
6
+ TLS is required.
7
+
8
+ ## How the bot behaves
9
+
10
+ Before setup, here's the part most people want to know: this bot answers **every
11
+ text message it receives**. There is no `@mention` gate.
12
+
13
+ | Context | Behavior |
14
+ |---------|----------|
15
+ | **Private chats** | Responds to every text message. |
16
+ | **Group chats** | Responds to every text message it can see in any chat. No `@mention` required. Scope where it listens with `allowedChatIds`. |
17
+ | **Its own messages** | Ignored. The bot skips messages it sent itself, so it can't loop. |
18
+ | **Non-text messages** | Skipped. Photos, stickers, voice, and other media are not handled — only text messages reach the agent. |
19
+ | **Slash commands** | `/new`, `/status`, `/help` are handled locally and never sent to the model (see [Commands](#commands)). |
20
+
21
+ While a turn runs, the bot shows Telegram's **typing…** indicator and re-sends it
22
+ every ~4 seconds (Telegram clears the state on its own) until the reply lands.
23
+
24
+ Replies are chunked at **4096 characters** (Telegram's per-message limit) and each
25
+ chunk is sent with the configured **parse mode** (`MarkdownV2` by default). Telegram,
26
+ unlike Discord, needs a parse mode to render formatting — and if it rejects a chunk's
27
+ formatting, that chunk is **re-sent as plain text**, so a stray markdown character
28
+ never silently drops the reply.
29
+
30
+ If a new message arrives while the agent is mid-reply, it is **queued as a follow-up**
31
+ and answered after the current turn finishes — it does not interrupt the in-flight
32
+ response.
33
+
34
+ ## Commands
35
+
36
+ These commands are registered as the bot's command menu at producer startup and are
37
+ handled locally by the inbound routing workflow, not forwarded to the model:
38
+
39
+ | Command | Action |
40
+ |---------|--------|
41
+ | `/new` | Start a fresh session for this chat. The new session becomes the active one; the previous session stays addressable but new messages route to the new one. |
42
+ | `/status` | Show the current session name and its current model. |
43
+ | `/help` | List the available commands. |
44
+
45
+ Any other `/command` returns `Unknown command: /<name>. Try /help.`
46
+
47
+ ## Session model
48
+
49
+ Each chat gets its **own wolli session**, bound by a `telegram:chat` tag:
50
+
51
+ - **Inbound** — an incoming message is routed to the session tagged for its chat. If
52
+ none exists yet, one is created and tagged on the spot. Histories never bleed across
53
+ chats, and two chats can run in parallel.
54
+ - **Outbound** — the reply rides the producing session's tag, so the answer always
55
+ returns to the chat that started the turn — not to whoever messaged most recently.
56
+
57
+ `/new` creates a fresh session tagged for the chat; because it becomes the newest
58
+ match for that tag, subsequent messages route to it.
59
+
60
+ ## Setup
61
+
62
+ ### 1. Create a bot with BotFather
63
+
64
+ Open [@BotFather](https://t.me/BotFather) in Telegram and send `/newbot`. Pick a name
65
+ and a username; BotFather replies with a **bot token** like `123456:ABC-DEF...`. Copy
66
+ it — you'll paste it in the next step. Keep it secret.
67
+
68
+ ### 2. Install and onboard in wolli
69
+
70
+ ```bash
71
+ wolli <agent> plugins install ./built-in/plugins/telegram
72
+ ```
73
+
74
+ In an interactive terminal this runs onboarding immediately: it prints the BotFather
75
+ walkthrough, then prompts you to **paste the bot token**. Paste the token from step 1.
76
+ Wolli verifies it with a live `getMe()` call and stores it. That single token is the
77
+ only value you enter.
78
+
79
+ If you installed non-interactively, onboard later with:
80
+
81
+ ```bash
82
+ wolli <agent> plugins configure telegram
83
+ ```
84
+
85
+ ### 3. Restart the agent
86
+
87
+ ```bash
88
+ wolli restart <agent>
89
+ ```
90
+
91
+ This starts the long-poll producer that connects to Telegram. After onboarding a
92
+ fresh integration its producer starts only on the next daemon start, so restart the
93
+ agent once for the bot to come online and begin responding.
94
+
95
+ ## Configuration reference
96
+
97
+ Configuration lives per agent in `~/.wolli/agents/<name>/integrations.json` under
98
+ `telegram.default`:
99
+
100
+ ```json
101
+ {
102
+ "telegram": {
103
+ "default": {
104
+ "botToken": "123456:ABC-DEF...",
105
+ "allowedChatIds": [123456789],
106
+ "parseMode": "MarkdownV2"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ | Field | Required | Default | Purpose |
113
+ |-------|----------|---------|---------|
114
+ | `botToken` | Yes | — | The BotFather token. Onboarding stores it raw; a `$ENV` / `!cmd` reference placed here by hand also resolves on read. |
115
+ | `allowedChatIds` | No | *(any chat)* | Allowlist of chat IDs the bot will respond in. Empty or absent accepts **any** chat (logged as a warning at startup). |
116
+ | `parseMode` | No | `MarkdownV2` | Outbound formatting: `"MarkdownV2"`, `"HTML"`, or `"plain"` (disables parse mode). |
117
+
118
+ Telegram chat IDs are **numbers** — keep them unquoted in JSON, unlike Discord's
119
+ quoted snowflakes.
120
+
121
+ `allowedChatIds` and `parseMode` are not asked during onboarding — edit
122
+ `integrations.json` to set them.
123
+
124
+ ## Not supported
125
+
126
+ The bot is deliberately focused on text chat. It does not provide:
127
+
128
+ - A durable cursor — on start it calls `deleteWebhook({ drop_pending_updates: true })`,
129
+ so a restart never replays a backlog but also drops messages sent while the bot was
130
+ offline.
131
+ - Inbound media or images — only text messages are handled.
132
+ - Webhook mode — the transport is long polling only.
133
+ - Callback queries or inline keyboards.
134
+ - Outbound rate-limit throttling.
135
+ - Mention-gating, or per-user / per-role allowlists — chat-level `allowedChatIds` is
136
+ the only scope control.
137
+
138
+ ## Troubleshooting
139
+
140
+ | Symptom | Fix |
141
+ |---------|-----|
142
+ | No reply right after the first install/onboard | Restart the agent (`wolli restart <agent>`) so the long-poll producer starts. |
143
+ | Bot ignores messages sent while it was offline | Expected — `deleteWebhook({ drop_pending_updates: true })` clears the backlog on start. The bot only sees messages that arrive while it is running. |
144
+ | Formatting looks broken, or a message seems to drop | A chunk that fails parse-mode parsing is re-sent as plain text. Set `parseMode: "plain"` to disable formatting entirely. |
145
+
146
+ ## Security
147
+
148
+ - `allowedChatIds` is the only access gate. With it empty, **any** chat is accepted —
149
+ set it to lock the bot to known chats.
150
+ - There is no per-user gate beyond that. Anyone who can post in an allowed chat can
151
+ drive the agent, so keep the bot in trusted chats.
152
+ - The bot token grants full control of the bot account — never commit it or share it.
153
+ Wolli writes `integrations.json` with mode `0600`.