@interactive-inc/flume 0.1.0 → 0.2.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 +98 -46
- package/dist/connection-error-BOk97djj.d.ts +6 -0
- package/dist/discord.d.ts +111 -4
- package/dist/discord.js +537 -2
- package/dist/github.d.ts +50 -4
- package/dist/github.js +307 -2
- package/dist/http-error-BtXonO-W.js +12 -0
- package/dist/http-error-K-Ym4lfK.d.ts +11 -0
- package/dist/index.d.ts +48 -220
- package/dist/index.js +90 -52
- package/dist/logger-CpGB9WO_.js +49 -0
- package/dist/parse-error-BAiCLRmk.d.ts +6 -0
- package/dist/safe-fetch-30ZzOKHL.js +20 -0
- package/dist/safe-json-parse-WCg_x1JS.js +15 -0
- package/dist/{safe-json-parse-D8t_4Vm_.js → schedule-reconnect-DSxZJG3h.js} +24 -12
- package/dist/serial-queue-ExmlnpzQ.js +16 -0
- package/dist/slack.d.ts +65 -4
- package/dist/slack.js +443 -2
- package/dist/{types-BVQSU336.d.ts → types-tnOPBc1p.d.ts} +32 -16
- package/package.json +1 -1
- package/dist/discord-source-Q4JRIbNs.js +0 -525
- package/dist/github-source-D7Z1RUbe.js +0 -280
- package/dist/logger-B9E8zvgj.js +0 -69
- package/dist/slack-source-CszepStG.js +0 -400
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@ Unified notification listener for Discord, Slack, and GitHub. Raw WebSocket + `f
|
|
|
4
4
|
|
|
5
5
|
```
|
|
6
6
|
Discord ─┐
|
|
7
|
-
Slack
|
|
7
|
+
Slack ─┼──▶ Flume ──start(handler)──▶ FlumeEvent (one merged stream)
|
|
8
8
|
GitHub ─┘
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
Flume only **receives**. It opens the WebSocket / polls the API, parses the payload with Zod, and hands you a typed event. Sending replies is out of scope — bring your own HTTP call.
|
|
11
|
+
Flume only **receives**. It opens the WebSocket / polls the API, parses the payload with Zod, serializes events through a per-source queue, and hands you a typed event. Sending replies is out of scope — bring your own HTTP call.
|
|
12
12
|
|
|
13
13
|
## Install
|
|
14
14
|
|
|
@@ -19,62 +19,96 @@ npm add @interactive-inc/flume
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
21
21
|
```ts
|
|
22
|
-
import { Flume
|
|
22
|
+
import { Flume } from "@interactive-inc/flume"
|
|
23
|
+
import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
|
|
24
|
+
import { FlumeSlackSource } from "@interactive-inc/flume/slack"
|
|
25
|
+
import { FlumeGitHubSource } from "@interactive-inc/flume/github"
|
|
26
|
+
|
|
27
|
+
const onLog = (log) => console.log(`[${log.level}] ${log.source}/${log.action}: ${log.message}`)
|
|
23
28
|
|
|
24
29
|
const flume = new Flume({
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
30
|
+
sources: [
|
|
31
|
+
new FlumeDiscordSource({ token: process.env.DISCORD_BOT_TOKEN!, onLog, reconnect: true }),
|
|
32
|
+
new FlumeSlackSource({ appToken: process.env.SLACK_APP_TOKEN!, onLog, reconnect: true }),
|
|
33
|
+
new FlumeGitHubSource({ token: process.env.GITHUB_TOKEN!, onLog, pollInterval: 60 }),
|
|
34
|
+
],
|
|
29
35
|
})
|
|
30
36
|
|
|
31
|
-
const
|
|
32
|
-
const slack = flume.slack({ appToken: process.env.SLACK_APP_TOKEN! })
|
|
33
|
-
const github = flume.github({ token: process.env.GITHUB_TOKEN!, pollInterval: 60 })
|
|
34
|
-
|
|
35
|
-
await discord.start((event) => {
|
|
37
|
+
const running = await flume.start((event) => {
|
|
36
38
|
console.log(event.source, event.type, event.meta)
|
|
37
39
|
})
|
|
38
40
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
if (running instanceof Error) throw running
|
|
42
|
+
|
|
43
|
+
// later
|
|
44
|
+
await running.stop()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Lifecycle (type-state FSM)
|
|
48
|
+
|
|
49
|
+
`Flume` enforces lifecycle correctness through three classes — misuse becomes a compile error.
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
Flume ──start()──▶ FlumeRunning ──stop()──▶ FlumeStopped
|
|
53
|
+
(idle) (running) (terminal)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- `Flume.start(handler)` returns `FlumeRunning | Error`. On partial failure (one source fails while another succeeds), the already-started sources are rolled back and an `Error` is returned with per-source detail.
|
|
57
|
+
- `FlumeRunning.stop()` returns a `FlumeStopped` snapshot. `stop()` is idempotent and concurrent-safe.
|
|
58
|
+
- `FlumeStopped` exposes only `statuses()` — a frozen snapshot of each source's final state. No `start`, no `stop`, no leaking source references.
|
|
59
|
+
- An `AbortSignal` on `Flume` drives an automatic transition to `FlumeStopped`.
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
const running = await flume.start(handler)
|
|
63
|
+
if (running instanceof Error) {
|
|
64
|
+
console.error(running.message)
|
|
65
|
+
// "Flume.start: 1 source(s) failed: slack: connect refused"
|
|
66
|
+
return
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
running.start() // type error — `start` is not on FlumeRunning
|
|
70
|
+
|
|
71
|
+
const stopped = await running.stop()
|
|
72
|
+
stopped.stop() // type error
|
|
73
|
+
stopped.start() // type error
|
|
74
|
+
stopped.statuses() // [{ name: "discord", status: "disconnected" }, ...]
|
|
41
75
|
```
|
|
42
76
|
|
|
43
77
|
## Direct source usage
|
|
44
78
|
|
|
45
|
-
|
|
79
|
+
Sources work standalone — `Flume` is only needed for multi-source orchestration.
|
|
46
80
|
|
|
47
81
|
```ts
|
|
48
|
-
import { FlumeDiscordSource
|
|
82
|
+
import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
|
|
49
83
|
|
|
50
84
|
const source = new FlumeDiscordSource({
|
|
51
85
|
token: process.env.DISCORD_BOT_TOKEN!,
|
|
52
|
-
deps: createFlumeDefaultDeps(),
|
|
53
86
|
reconnect: true,
|
|
54
87
|
onLog: (log) => console.log(log),
|
|
55
88
|
})
|
|
56
89
|
|
|
57
|
-
await source.start((event) => { /* ... */ })
|
|
90
|
+
const error = await source.start((event) => { /* ... */ })
|
|
91
|
+
if (error instanceof Error) throw error
|
|
58
92
|
```
|
|
59
93
|
|
|
60
94
|
## Sub-entries
|
|
61
95
|
|
|
62
|
-
Each source
|
|
96
|
+
Each source has a dedicated entry — importing one does not pull the others into your bundle. The root entry never loads source-specific code.
|
|
63
97
|
|
|
64
|
-
| sub-entry
|
|
65
|
-
|
|
66
|
-
| `@interactive-inc/flume
|
|
67
|
-
| `@interactive-inc/flume/
|
|
68
|
-
| `@interactive-inc/flume/
|
|
98
|
+
| sub-entry | exports |
|
|
99
|
+
|------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
|
100
|
+
| `@interactive-inc/flume` | `Flume`, `FlumeRunning`, `FlumeStopped`, `FlumeLogger`, `FlumeReconnector`, `scheduleFlumeReconnect`, `createFlumeDefaultDeps`, errors, types |
|
|
101
|
+
| `@interactive-inc/flume/discord` | `FlumeDiscordSource`, `FlumeDiscordGateway`, `FlumeDiscordGatewayIntents`, `FlumeDiscordHeartbeat`, `FlumeDiscordGatewaySession`, `parseDiscordGatewayMessage`, `extractDiscordMeta`, `FlumeGatewayMessageSchema` |
|
|
102
|
+
| `@interactive-inc/flume/slack` | `FlumeSlackSource`, `FlumeSlackSocketMode`, `FlumeSlackSeenCache`, `obtainSlackUrl`, `extractSlackMeta`, `FlumeSlackEnvelopeSchema`, `FlumeSlackConnectionResponseSchema` |
|
|
103
|
+
| `@interactive-inc/flume/github` | `FlumeGitHubSource`, `FlumeGitHubPoller`, `FlumeGitHubSeenCache`, `extractGitHubMeta`, `FlumeGitHubNotificationSchema` |
|
|
69
104
|
|
|
70
105
|
```ts
|
|
71
|
-
import {
|
|
106
|
+
import { Flume } from "@interactive-inc/flume"
|
|
72
107
|
import { FlumeDiscordSource } from "@interactive-inc/flume/discord"
|
|
108
|
+
import { FlumeSlackSource } from "@interactive-inc/flume/slack"
|
|
73
109
|
import { FlumeGitHubSource } from "@interactive-inc/flume/github"
|
|
74
110
|
```
|
|
75
111
|
|
|
76
|
-
The root entry exports the `Flume` container, every source class, every protocol class (`FlumeDiscordGateway`, `FlumeSlackSocketMode`, `FlumeGitHubPoller`), `FlumeReconnector`, `FlumeLogger`, `createFlumeDefaultDeps`, every Zod schema, every error class, and all public types.
|
|
77
|
-
|
|
78
112
|
## Event shape
|
|
79
113
|
|
|
80
114
|
Every source emits the same `FlumeEvent`:
|
|
@@ -163,32 +197,47 @@ GitHub populates `detail` with the failure reason (e.g. `"HTTP 500"`, `"network
|
|
|
163
197
|
|
|
164
198
|
## Cancellation
|
|
165
199
|
|
|
166
|
-
Pass an `AbortSignal` to
|
|
200
|
+
Pass an `AbortSignal` to `Flume` (propagates to every source) or to an individual source.
|
|
167
201
|
|
|
168
202
|
```ts
|
|
169
203
|
const controller = new AbortController()
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
204
|
+
|
|
205
|
+
const flume = new Flume({
|
|
206
|
+
sources: [new FlumeDiscordSource({ token, signal: controller.signal })],
|
|
207
|
+
signal: controller.signal,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
const running = await flume.start(handler)
|
|
211
|
+
if (running instanceof Error) throw running
|
|
212
|
+
|
|
213
|
+
controller.abort() // FlumeRunning auto-transitions to FlumeStopped
|
|
173
214
|
```
|
|
174
215
|
|
|
216
|
+
If the signal is already aborted at `Flume.start()` time, `start` returns an `Error` and no source is touched.
|
|
217
|
+
|
|
175
218
|
## Dependency injection
|
|
176
219
|
|
|
177
|
-
Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`.
|
|
220
|
+
Every IO boundary (`fetch`, `WebSocket`, `now`, `random`, timers) lives in `FlumeRuntimeDeps`. `deps` is optional on every source — when omitted, `createFlumeDefaultDeps()` wraps the global equivalents. Override only when you need mocks or runtime-specific shims.
|
|
178
221
|
|
|
179
222
|
```ts
|
|
180
223
|
import { createFlumeDefaultDeps } from "@interactive-inc/flume"
|
|
181
224
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
225
|
+
new FlumeDiscordSource({
|
|
226
|
+
token,
|
|
227
|
+
deps: {
|
|
228
|
+
...createFlumeDefaultDeps(),
|
|
229
|
+
fetch: mockFetch,
|
|
230
|
+
now: () => 1_000,
|
|
231
|
+
},
|
|
232
|
+
})
|
|
189
233
|
```
|
|
190
234
|
|
|
191
|
-
|
|
235
|
+
## Safety
|
|
236
|
+
|
|
237
|
+
- **Backpressure** — each source has its own `FlumeSerialQueue`. Handler invocations are awaited and run one at a time per source, so async handlers don't race and `stop()` drains in-flight events before transitioning state.
|
|
238
|
+
- **Duplicate suppression** — Slack envelopes are deduped by `envelope_id` (`FlumeSlackSeenCache`) to absorb ack retries. GitHub notifications are deduped by `id + updated_at` (`FlumeGitHubSeenCache`). Discord uses session resume so the Gateway does not re-emit dispatches.
|
|
239
|
+
- **Partial-failure rollback** — if any source fails during `Flume.start()`, the already-started sources are stopped and an `Error` is returned with per-source detail.
|
|
240
|
+
- **Idempotent stop** — `FlumeRunning.stop()` is safe to call concurrently; the first call wins and subsequent callers receive the same `FlumeStopped` snapshot.
|
|
192
241
|
|
|
193
242
|
## Errors
|
|
194
243
|
|
|
@@ -213,18 +262,21 @@ GitHub also exposes `gh auth token` if you want to reuse the `gh` CLI's session:
|
|
|
213
262
|
```ts
|
|
214
263
|
import { execSync } from "node:child_process"
|
|
215
264
|
const token = execSync("gh auth token").toString().trim()
|
|
216
|
-
const github =
|
|
265
|
+
const github = new FlumeGitHubSource({ token })
|
|
217
266
|
```
|
|
218
267
|
|
|
219
268
|
## Module layout
|
|
220
269
|
|
|
221
|
-
- `Flume`
|
|
222
|
-
- `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources
|
|
270
|
+
- `Flume` / `FlumeRunning` / `FlumeStopped` — type-state FSM merging multiple sources into one stream
|
|
271
|
+
- `FlumeDiscordSource` / `FlumeSlackSource` / `FlumeGitHubSource` — high-level sources (each conforms to the structural `FlumeSource` type)
|
|
223
272
|
- `FlumeDiscordGateway` / `FlumeSlackSocketMode` / `FlumeGitHubPoller` — protocol layer
|
|
224
|
-
- `
|
|
273
|
+
- `FlumeDiscordGatewaySession` — immutable session value object (id / seq / resume URL) carried across Discord reconnects
|
|
274
|
+
- `FlumeSlackSeenCache` / `FlumeGitHubSeenCache` — per-source duplicate suppression
|
|
275
|
+
- `FlumeReconnector` + `scheduleFlumeReconnect` — exponential backoff with jitter + shared reconnect scheduler
|
|
225
276
|
- `FlumeLogger` — structured log emitter (feeds `onLog`)
|
|
226
|
-
- `FlumeRuntimeDeps` — IO boundary port
|
|
227
|
-
-
|
|
277
|
+
- `FlumeRuntimeDeps` — IO boundary port (`fetch`, `WebSocket`, `now`, `random`, timers)
|
|
278
|
+
- `extractDiscordMeta` / `extractSlackMeta` / `extractGitHubMeta` — pure functions that build `FlumeEvent.meta` from each protocol's payload shape
|
|
279
|
+
- Per-source Zod schemas: `FlumeGatewayMessageSchema` (discord), `FlumeSlackEnvelopeSchema` / `FlumeSlackConnectionResponseSchema` (slack), `FlumeGitHubNotificationSchema` (github)
|
|
228
280
|
|
|
229
281
|
## Development
|
|
230
282
|
|
package/dist/discord.d.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
|
-
import { o as FlumeHandler, t as FlumeDiscordSourceOptions,
|
|
1
|
+
import { D as FlumeGatewayMessageSchema, c as FlumeLogHandler, o as FlumeHandler, p as FlumeRuntimeDeps, r as FlumeGatewayMessage, t as FlumeDiscordSourceOptions, x as FlumeStatus } from "./types-tnOPBc1p.js";
|
|
2
|
+
import { t as FlumeConnectionError } from "./connection-error-BOk97djj.js";
|
|
3
|
+
import { t as FlumeParseError } from "./parse-error-BAiCLRmk.js";
|
|
2
4
|
|
|
3
5
|
//#region lib/discord/discord-source.d.ts
|
|
4
6
|
declare class FlumeDiscordSource {
|
|
5
7
|
private readonly options;
|
|
8
|
+
readonly name: "discord";
|
|
6
9
|
private gateway;
|
|
7
10
|
private reconnector;
|
|
8
11
|
private handler;
|
|
9
12
|
private currentStatus;
|
|
10
13
|
private readonly log;
|
|
11
14
|
private readonly deps;
|
|
15
|
+
private readonly queue;
|
|
12
16
|
constructor(options: FlumeDiscordSourceOptions);
|
|
13
|
-
start(handler: FlumeHandler): Promise<void>;
|
|
17
|
+
start(handler: FlumeHandler): Promise<void | Error>;
|
|
14
18
|
stop(): Promise<void>;
|
|
15
19
|
status(): FlumeStatus;
|
|
16
20
|
private connectInternal;
|
|
@@ -18,7 +22,110 @@ declare class FlumeDiscordSource {
|
|
|
18
22
|
private handleGatewayStatus;
|
|
19
23
|
private scheduleReconnect;
|
|
20
24
|
private setStatus;
|
|
21
|
-
static extractMeta(eventName: string, eventData: Record<string, unknown>): Record<string, string>;
|
|
22
25
|
}
|
|
23
26
|
//#endregion
|
|
24
|
-
|
|
27
|
+
//#region lib/discord/extract-discord-meta.d.ts
|
|
28
|
+
declare function extractDiscordMeta(eventName: string, eventData: Record<string, unknown>): Record<string, string>;
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region lib/discord/discord-gateway-session.d.ts
|
|
31
|
+
type Props$2 = {
|
|
32
|
+
sessionId: string | null;
|
|
33
|
+
resumeUrl: string | null;
|
|
34
|
+
seq: number | null;
|
|
35
|
+
};
|
|
36
|
+
declare class FlumeDiscordGatewaySession {
|
|
37
|
+
readonly sessionId: string | null;
|
|
38
|
+
readonly resumeUrl: string | null;
|
|
39
|
+
readonly seq: number | null;
|
|
40
|
+
constructor(props: Props$2);
|
|
41
|
+
static empty(): FlumeDiscordGatewaySession;
|
|
42
|
+
canResume(): boolean;
|
|
43
|
+
withSeq(seq: number): FlumeDiscordGatewaySession;
|
|
44
|
+
withReady(sessionId: string, resumeUrl: string): FlumeDiscordGatewaySession;
|
|
45
|
+
withReset(): FlumeDiscordGatewaySession;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region lib/discord/discord-gateway.d.ts
|
|
49
|
+
type Deps = Pick<FlumeRuntimeDeps, "WebSocket" | "setInterval" | "clearInterval" | "setTimeout" | "random" | "now">;
|
|
50
|
+
type Props$1 = {
|
|
51
|
+
token: string;
|
|
52
|
+
intents: number;
|
|
53
|
+
onDispatch: (event: string, data: Record<string, unknown>) => void;
|
|
54
|
+
onStatus: (status: "connected" | "disconnected") => void;
|
|
55
|
+
onLog?: FlumeLogHandler;
|
|
56
|
+
deps: Deps;
|
|
57
|
+
};
|
|
58
|
+
declare class FlumeDiscordGateway {
|
|
59
|
+
private readonly props;
|
|
60
|
+
private readonly log;
|
|
61
|
+
private ws;
|
|
62
|
+
private heartbeat;
|
|
63
|
+
session: FlumeDiscordGatewaySession;
|
|
64
|
+
stopped: boolean;
|
|
65
|
+
private pendingResolve;
|
|
66
|
+
private pendingResolved;
|
|
67
|
+
constructor(props: Props$1);
|
|
68
|
+
connect(url?: string): Promise<FlumeConnectionError | null>;
|
|
69
|
+
disconnect(): void;
|
|
70
|
+
isConnected(): boolean;
|
|
71
|
+
private completeConnect;
|
|
72
|
+
private onMessage;
|
|
73
|
+
private onHello;
|
|
74
|
+
private onHeartbeatAck;
|
|
75
|
+
private onHeartbeatRequest;
|
|
76
|
+
private onReconnectRequest;
|
|
77
|
+
private onInvalidSession;
|
|
78
|
+
private onDispatch;
|
|
79
|
+
private onClose;
|
|
80
|
+
private onError;
|
|
81
|
+
private send;
|
|
82
|
+
private sendIdentify;
|
|
83
|
+
private sendResume;
|
|
84
|
+
}
|
|
85
|
+
//#endregion
|
|
86
|
+
//#region lib/discord/discord-gateway-intents.d.ts
|
|
87
|
+
declare const FlumeDiscordGatewayIntents: {
|
|
88
|
+
readonly Guilds: number;
|
|
89
|
+
readonly GuildMembers: number;
|
|
90
|
+
readonly GuildModeration: number;
|
|
91
|
+
readonly GuildExpressions: number;
|
|
92
|
+
readonly GuildIntegrations: number;
|
|
93
|
+
readonly GuildWebhooks: number;
|
|
94
|
+
readonly GuildInvites: number;
|
|
95
|
+
readonly GuildVoiceStates: number;
|
|
96
|
+
readonly GuildPresences: number;
|
|
97
|
+
readonly GuildMessages: number;
|
|
98
|
+
readonly GuildMessageReactions: number;
|
|
99
|
+
readonly GuildMessageTyping: number;
|
|
100
|
+
readonly DirectMessages: number;
|
|
101
|
+
readonly DirectMessageReactions: number;
|
|
102
|
+
readonly DirectMessageTyping: number;
|
|
103
|
+
readonly MessageContent: number;
|
|
104
|
+
readonly GuildScheduledEvents: number;
|
|
105
|
+
readonly AutoModerationConfiguration: number;
|
|
106
|
+
readonly AutoModerationExecution: number;
|
|
107
|
+
readonly GuildMessagePolls: number;
|
|
108
|
+
readonly DirectMessagePolls: number;
|
|
109
|
+
};
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region lib/discord/discord-heartbeat.d.ts
|
|
112
|
+
type Props = {
|
|
113
|
+
onSend: () => void;
|
|
114
|
+
onZombie: () => void;
|
|
115
|
+
deps: Pick<FlumeRuntimeDeps, "setInterval" | "clearInterval">;
|
|
116
|
+
};
|
|
117
|
+
declare class FlumeDiscordHeartbeat {
|
|
118
|
+
private readonly props;
|
|
119
|
+
private timer;
|
|
120
|
+
private ackReceived;
|
|
121
|
+
constructor(props: Props);
|
|
122
|
+
start(intervalMs: number): void;
|
|
123
|
+
stop(): void;
|
|
124
|
+
ack(): void;
|
|
125
|
+
isRunning(): boolean;
|
|
126
|
+
}
|
|
127
|
+
//#endregion
|
|
128
|
+
//#region lib/discord/parse-discord-gateway-message.d.ts
|
|
129
|
+
declare function parseDiscordGatewayMessage(raw: string): FlumeGatewayMessage | FlumeParseError;
|
|
130
|
+
//#endregion
|
|
131
|
+
export { FlumeDiscordGateway, FlumeDiscordGatewayIntents, FlumeDiscordGatewaySession, FlumeDiscordHeartbeat, FlumeDiscordSource, FlumeGatewayMessageSchema, extractDiscordMeta, parseDiscordGatewayMessage };
|