@headless-lms/plugin-slack 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +39 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +253 -0
- package/dist/index.js.map +1 -0
- package/package.json +45 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mdwt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# @headless-lms/plugin-slack
|
|
2
|
+
|
|
3
|
+
Slack integration for the platform: posts domain events (e.g. student
|
|
4
|
+
entitlements) to a Slack channel, formatted per event type.
|
|
5
|
+
|
|
6
|
+
## Actions
|
|
7
|
+
|
|
8
|
+
| id | What it does |
|
|
9
|
+
| --- | --- |
|
|
10
|
+
| `postToChannel` | Posts a domain event to a channel. Input: `{ channel?, body }` where `body` is the event (`{ type, ...metadata }`). `entitlement.*` events get rich Block Kit formatting; unknown types post generically. `channel` falls back to the connection's `defaultChannel`. |
|
|
11
|
+
| `listChannels` | Lists public channels (for channel pickers). Paginated via `cursor`/`limit`. |
|
|
12
|
+
|
|
13
|
+
Formatted entitlement events: `entitlement.created`, `entitlement.updated`,
|
|
14
|
+
`entitlement.deleted`, `entitlement.expired` — each carrying an `entitlement`
|
|
15
|
+
object (student name/email, a `content` ref `{ id, type, title }`, and
|
|
16
|
+
granted/expires timestamps). Course grants keep "enrolled" wording; other
|
|
17
|
+
content types read "granted access to".
|
|
18
|
+
|
|
19
|
+
## Connecting
|
|
20
|
+
|
|
21
|
+
1. Create a Slack app at [api.slack.com/apps](https://api.slack.com/apps).
|
|
22
|
+
2. OAuth & Permissions → add bot scopes `chat:write` (post) and `channels:read` (list).
|
|
23
|
+
3. Install to workspace and copy the Bot User OAuth Token (`xoxb-…`).
|
|
24
|
+
4. Connect the integration with secret `botToken` and config `defaultChannel`
|
|
25
|
+
(invite the bot to that channel).
|
|
26
|
+
|
|
27
|
+
## Wiring
|
|
28
|
+
|
|
29
|
+
Implements the `Integration` contract from `@headless-lms/core/types`, using the
|
|
30
|
+
zod helpers from `@headless-lms/core/integrations`.
|
|
31
|
+
The api loads it through the plugin folder convention:
|
|
32
|
+
`apps/api/src/plugins/slack/index.ts` re-exports this package's default export.
|
|
33
|
+
|
|
34
|
+
## Develop
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pnpm --filter @headless-lms/plugin-slack build
|
|
38
|
+
pnpm --filter @headless-lms/plugin-slack test
|
|
39
|
+
```
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { zodAction, zodConfig, zodSecrets } from "@headless-lms/core/integrations";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
//#region src/schemas.ts
|
|
4
|
+
const SlackSecrets = z.object({ botToken: z.string().min(1).describe("Slack bot token (xoxb-…). Create a Slack app at api.slack.com/apps → OAuth & Permissions → add the bot scopes chat:write (post messages) and channels:read (list channels) → Install to Workspace → copy the Bot User OAuth Token.") });
|
|
5
|
+
const SlackConfig = z.object({ defaultChannel: z.string().min(1).describe("Channel to post to when an action doesn't name one (e.g. \"#general\"). The bot must be a member of the channel — invite it with /invite.") });
|
|
6
|
+
//#endregion
|
|
7
|
+
//#region src/client.ts
|
|
8
|
+
async function slackApi(ctx, method, init, query) {
|
|
9
|
+
const url = `https://slack.com/api/${method}${query ? `?${query.toString()}` : ""}`;
|
|
10
|
+
const res = await fetch(url, {
|
|
11
|
+
...init,
|
|
12
|
+
headers: {
|
|
13
|
+
authorization: `Bearer ${ctx.botToken}`,
|
|
14
|
+
...init.headers
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const body = await res.json();
|
|
18
|
+
if (!res.ok || !body.ok) throw new Error(`slack ${method} failed: ${body.error ?? res.status}`);
|
|
19
|
+
return body;
|
|
20
|
+
}
|
|
21
|
+
async function postMessage(ctx, args) {
|
|
22
|
+
const body = await slackApi(ctx, "chat.postMessage", {
|
|
23
|
+
method: "POST",
|
|
24
|
+
headers: { "content-type": "application/json; charset=utf-8" },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
channel: args.channel,
|
|
27
|
+
text: args.text,
|
|
28
|
+
blocks: args.blocks
|
|
29
|
+
})
|
|
30
|
+
});
|
|
31
|
+
return {
|
|
32
|
+
channel: args.channel,
|
|
33
|
+
ts: body.ts ?? ""
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
async function listChannels$1(ctx, args) {
|
|
37
|
+
const query = new URLSearchParams({
|
|
38
|
+
types: "public_channel",
|
|
39
|
+
exclude_archived: "true",
|
|
40
|
+
limit: String(args.limit ?? 100)
|
|
41
|
+
});
|
|
42
|
+
if (args.cursor) query.set("cursor", args.cursor);
|
|
43
|
+
const body = await slackApi(ctx, "conversations.list", { method: "GET" }, query);
|
|
44
|
+
const nextCursor = body.response_metadata?.next_cursor;
|
|
45
|
+
return {
|
|
46
|
+
channels: (body.channels ?? []).map(({ id, name }) => ({
|
|
47
|
+
id,
|
|
48
|
+
name
|
|
49
|
+
})),
|
|
50
|
+
...nextCursor ? { nextCursor } : {}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/notifications/schema.ts
|
|
55
|
+
const EventBody = z.looseObject({ type: z.string().min(1).describe("Domain event type, e.g. \"entitlement.created\".") }).describe("A domain event: its type plus the metadata the event carries.");
|
|
56
|
+
/** The content reference an entitlement carries: identity + display name. */
|
|
57
|
+
const ContentRefPayload = z.object({
|
|
58
|
+
id: z.string(),
|
|
59
|
+
type: z.string().describe("Content type, e.g. \"course\"."),
|
|
60
|
+
title: z.string()
|
|
61
|
+
});
|
|
62
|
+
/** The metadata entitlement.* events carry (mirrors the domain event snapshot). */
|
|
63
|
+
const EntitlementEventPayload = z.object({
|
|
64
|
+
id: z.string().optional(),
|
|
65
|
+
firstName: z.string(),
|
|
66
|
+
lastName: z.string(),
|
|
67
|
+
studentEmail: z.string(),
|
|
68
|
+
content: ContentRefPayload,
|
|
69
|
+
grantedAt: z.string().describe("ISO-8601 timestamp"),
|
|
70
|
+
expiresAt: z.string().nullable().optional().describe("ISO-8601 timestamp, if access expires")
|
|
71
|
+
});
|
|
72
|
+
/** Validates an entitlement.* body's metadata before rich formatting. */
|
|
73
|
+
const EntitlementEventBody = z.looseObject({ entitlement: EntitlementEventPayload });
|
|
74
|
+
//#endregion
|
|
75
|
+
//#region src/notifications/formatters.ts
|
|
76
|
+
function formatMessage(body) {
|
|
77
|
+
switch (body.type) {
|
|
78
|
+
case "entitlement.created": return formatEntitlementCreated(entitlement(body));
|
|
79
|
+
case "entitlement.updated": return formatEntitlementUpdated(entitlement(body));
|
|
80
|
+
case "entitlement.deleted": return formatEntitlementDeleted(entitlement(body));
|
|
81
|
+
case "entitlement.expired": return formatEntitlementExpired(entitlement(body));
|
|
82
|
+
default: return formatGeneric(body);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function entitlement(body) {
|
|
86
|
+
return EntitlementEventBody.parse(body).entitlement;
|
|
87
|
+
}
|
|
88
|
+
function grantVerb(e) {
|
|
89
|
+
return e.content.type === "course" ? "enrolled in" : "granted access to";
|
|
90
|
+
}
|
|
91
|
+
function formatEntitlementCreated(e) {
|
|
92
|
+
return {
|
|
93
|
+
text: `✅ ${studentName(e)} ${grantVerb(e)} ${e.content.title}`,
|
|
94
|
+
blocks: [
|
|
95
|
+
header("✅ New entitlement"),
|
|
96
|
+
entitlementFields(e),
|
|
97
|
+
context(`Granted ${e.grantedAt}`)
|
|
98
|
+
]
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function formatEntitlementUpdated(e) {
|
|
102
|
+
return {
|
|
103
|
+
text: `🔄 ${studentName(e)}'s access to ${e.content.title} was updated`,
|
|
104
|
+
blocks: [
|
|
105
|
+
header("🔄 Entitlement updated"),
|
|
106
|
+
entitlementFields(e),
|
|
107
|
+
context(`Originally granted ${e.grantedAt}`)
|
|
108
|
+
]
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
function formatEntitlementDeleted(e) {
|
|
112
|
+
return {
|
|
113
|
+
text: `🚫 ${studentName(e)}'s access to ${e.content.title} was revoked`,
|
|
114
|
+
blocks: [
|
|
115
|
+
header("🚫 Entitlement removed"),
|
|
116
|
+
entitlementFields(e),
|
|
117
|
+
context(`Originally granted ${e.grantedAt}`)
|
|
118
|
+
]
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function formatEntitlementExpired(e) {
|
|
122
|
+
return {
|
|
123
|
+
text: `⏳ ${studentName(e)}'s access to ${e.content.title} has expired`,
|
|
124
|
+
blocks: [
|
|
125
|
+
header("⏳ Entitlement expired"),
|
|
126
|
+
entitlementFields(e),
|
|
127
|
+
context(`Originally granted ${e.grantedAt}`)
|
|
128
|
+
]
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function formatGeneric(body) {
|
|
132
|
+
const { type, ...data } = body;
|
|
133
|
+
const json = JSON.stringify(data, null, 2) ?? "{}";
|
|
134
|
+
const truncated = json.length > 2800 ? `${json.slice(0, 2800)}\n…` : json;
|
|
135
|
+
return {
|
|
136
|
+
text: `📣 ${type}`,
|
|
137
|
+
blocks: [header(`📣 ${type}`), {
|
|
138
|
+
type: "section",
|
|
139
|
+
text: {
|
|
140
|
+
type: "mrkdwn",
|
|
141
|
+
text: `\`\`\`${truncated}\`\`\``
|
|
142
|
+
}
|
|
143
|
+
}]
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
function studentName(e) {
|
|
147
|
+
return `${e.firstName} ${e.lastName}`;
|
|
148
|
+
}
|
|
149
|
+
function header(title) {
|
|
150
|
+
return {
|
|
151
|
+
type: "header",
|
|
152
|
+
text: {
|
|
153
|
+
type: "plain_text",
|
|
154
|
+
text: title,
|
|
155
|
+
emoji: true
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function field(label, value) {
|
|
160
|
+
return {
|
|
161
|
+
type: "mrkdwn",
|
|
162
|
+
text: `*${label}*\n${value}`
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function entitlementFields(e) {
|
|
166
|
+
const fields = [
|
|
167
|
+
field("Student", studentName(e)),
|
|
168
|
+
field("Email", e.studentEmail),
|
|
169
|
+
field(contentLabel(e), e.content.title),
|
|
170
|
+
field("Granted at", e.grantedAt)
|
|
171
|
+
];
|
|
172
|
+
if (e.expiresAt) fields.push(field("Expires", e.expiresAt));
|
|
173
|
+
return {
|
|
174
|
+
type: "section",
|
|
175
|
+
fields
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function contentLabel(e) {
|
|
179
|
+
const t = e.content.type;
|
|
180
|
+
return t.charAt(0).toUpperCase() + t.slice(1);
|
|
181
|
+
}
|
|
182
|
+
function context(text) {
|
|
183
|
+
return {
|
|
184
|
+
type: "context",
|
|
185
|
+
elements: [{
|
|
186
|
+
type: "mrkdwn",
|
|
187
|
+
text
|
|
188
|
+
}]
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
//#endregion
|
|
192
|
+
//#region src/actions/post-to-channel.ts
|
|
193
|
+
const postToChannel = zodAction({
|
|
194
|
+
id: "postToChannel",
|
|
195
|
+
description: "Post a message to a Slack channel.",
|
|
196
|
+
input: z.object({
|
|
197
|
+
channel: z.string().min(1).optional().meta({
|
|
198
|
+
description: "The channel to post to; the connection's default channel when omitted.",
|
|
199
|
+
"x-options": {
|
|
200
|
+
action: "listChannels",
|
|
201
|
+
items: "channels",
|
|
202
|
+
value: "id",
|
|
203
|
+
label: "name"
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
body: EventBody
|
|
207
|
+
}),
|
|
208
|
+
output: z.object({
|
|
209
|
+
channel: z.string(),
|
|
210
|
+
ts: z.string().describe("Slack's message timestamp.")
|
|
211
|
+
}),
|
|
212
|
+
run(ctx, input) {
|
|
213
|
+
const channel = input.channel ?? ctx.config.defaultChannel;
|
|
214
|
+
const message = formatMessage(input.body);
|
|
215
|
+
return postMessage({ botToken: ctx.secrets.botToken }, {
|
|
216
|
+
channel,
|
|
217
|
+
text: message.text,
|
|
218
|
+
blocks: message.blocks
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
//#endregion
|
|
223
|
+
//#region src/actions/list-channels.ts
|
|
224
|
+
const listChannels = zodAction({
|
|
225
|
+
id: "listChannels",
|
|
226
|
+
description: "List public Slack channels the connection can see (for channel pickers). Requires the channels:read scope.",
|
|
227
|
+
input: z.object({
|
|
228
|
+
cursor: z.string().optional().describe("Pagination cursor from a previous call."),
|
|
229
|
+
limit: z.number().int().min(1).max(200).default(100)
|
|
230
|
+
}),
|
|
231
|
+
output: z.object({
|
|
232
|
+
channels: z.array(z.object({
|
|
233
|
+
id: z.string(),
|
|
234
|
+
name: z.string()
|
|
235
|
+
})),
|
|
236
|
+
nextCursor: z.string().optional()
|
|
237
|
+
}),
|
|
238
|
+
run(ctx, input) {
|
|
239
|
+
return listChannels$1({ botToken: ctx.secrets.botToken }, input);
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/index.ts
|
|
244
|
+
const slack = {
|
|
245
|
+
id: "slack",
|
|
246
|
+
...zodConfig(SlackConfig),
|
|
247
|
+
...zodSecrets(SlackSecrets),
|
|
248
|
+
actions: [postToChannel, listChannels]
|
|
249
|
+
};
|
|
250
|
+
//#endregion
|
|
251
|
+
export { slack as default };
|
|
252
|
+
|
|
253
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["listChannels","fetchChannels"],"sources":["../src/schemas.ts","../src/client.ts","../src/notifications/schema.ts","../src/notifications/formatters.ts","../src/actions/post-to-channel.ts","../src/actions/list-channels.ts","../src/index.ts"],"sourcesContent":["// slack integration — connection secrets/config schemas. Descriptions flow\n// into the JSON Schema the admin UI renders, so they double as setup docs.\nimport { z } from \"zod\";\n\nexport const SlackSecrets = z.object({\n botToken: z\n .string()\n .min(1)\n .describe(\n \"Slack bot token (xoxb-…). Create a Slack app at api.slack.com/apps → \" +\n \"OAuth & Permissions → add the bot scopes chat:write (post messages) and \" +\n \"channels:read (list channels) → Install to Workspace → copy the Bot User OAuth Token.\",\n ),\n});\n\nexport const SlackConfig = z.object({\n defaultChannel: z\n .string()\n .min(1)\n .describe(\n 'Channel to post to when an action doesn\\'t name one (e.g. \"#general\"). ' +\n \"The bot must be a member of the channel — invite it with /invite.\",\n ),\n});\n","// slack integration — thin Slack Web API wrapper shared by all actions.\n// Owns transport concerns only (auth header, envelope parsing, error surfacing);\n// what to send lives with the actions/formatters.\n\nexport interface SlackClientCtx {\n botToken: string;\n}\n\ninterface SlackEnvelope {\n ok: boolean;\n error?: string;\n}\n\nasync function slackApi<T extends SlackEnvelope>(\n ctx: SlackClientCtx,\n method: string,\n init: RequestInit,\n query?: URLSearchParams,\n): Promise<T> {\n const url = `https://slack.com/api/${method}${query ? `?${query.toString()}` : \"\"}`;\n const res = await fetch(url, {\n ...init,\n headers: {\n authorization: `Bearer ${ctx.botToken}`,\n ...init.headers,\n },\n });\n const body = (await res.json()) as T;\n if (!res.ok || !body.ok) {\n throw new Error(`slack ${method} failed: ${body.error ?? res.status}`);\n }\n return body;\n}\n\nexport async function postMessage(\n ctx: SlackClientCtx,\n args: { channel: string; text: string; blocks?: unknown[] },\n): Promise<{ channel: string; ts: string }> {\n const body = await slackApi<SlackEnvelope & { ts?: string }>(ctx, \"chat.postMessage\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json; charset=utf-8\" },\n body: JSON.stringify({ channel: args.channel, text: args.text, blocks: args.blocks }),\n });\n return { channel: args.channel, ts: body.ts ?? \"\" };\n}\n\nexport interface SlackChannel {\n id: string;\n name: string;\n}\n\nexport async function listChannels(\n ctx: SlackClientCtx,\n args: { cursor?: string; limit?: number },\n): Promise<{ channels: SlackChannel[]; nextCursor?: string }> {\n // conversations.list does not accept a JSON body — arguments go in the query.\n const query = new URLSearchParams({\n types: \"public_channel\",\n exclude_archived: \"true\",\n limit: String(args.limit ?? 100),\n });\n if (args.cursor) {\n query.set(\"cursor\", args.cursor);\n }\n const body = await slackApi<\n SlackEnvelope & {\n channels?: Array<{ id: string; name: string }>;\n response_metadata?: { next_cursor?: string };\n }\n >(ctx, \"conversations.list\", { method: \"GET\" }, query);\n const nextCursor = body.response_metadata?.next_cursor;\n return {\n channels: (body.channels ?? []).map(({ id, name }) => ({ id, name })),\n ...(nextCursor ? { nextCursor } : {}),\n };\n}\n","// slack integration — input contract for posting domain events. The body is\n// any domain event: a required `type` plus whatever metadata the event\n// carries (unknown keys pass through). Known event types get rich formatting\n// (see formatters.ts); anything else falls back to a generic post, so new\n// domain events work immediately and earn a bespoke formatter later.\nimport { z } from \"zod\";\n\nexport const EventBody = z\n .looseObject({\n type: z.string().min(1).describe('Domain event type, e.g. \"entitlement.created\".'),\n })\n .describe(\"A domain event: its type plus the metadata the event carries.\");\nexport type EventBody = z.infer<typeof EventBody>;\n\n/** The content reference an entitlement carries: identity + display name. */\nexport const ContentRefPayload = z.object({\n id: z.string(),\n type: z.string().describe('Content type, e.g. \"course\".'),\n title: z.string(),\n});\nexport type ContentRefPayload = z.infer<typeof ContentRefPayload>;\n\n/** The metadata entitlement.* events carry (mirrors the domain event snapshot). */\nexport const EntitlementEventPayload = z.object({\n id: z.string().optional(),\n firstName: z.string(),\n lastName: z.string(),\n studentEmail: z.string(),\n content: ContentRefPayload,\n grantedAt: z.string().describe(\"ISO-8601 timestamp\"),\n expiresAt: z.string().nullable().optional().describe(\"ISO-8601 timestamp, if access expires\"),\n});\nexport type EntitlementEventPayload = z.infer<typeof EntitlementEventPayload>;\n\n/** Validates an entitlement.* body's metadata before rich formatting. */\nexport const EntitlementEventBody = z.looseObject({ entitlement: EntitlementEventPayload });\n","// slack integration — formats a domain event into a Slack message: switch on\n// the event type, format accordingly. `text` is the notification-fallback\n// string; `blocks` is the Block Kit layout. Unknown event types get a generic\n// post of the event's metadata rather than an error.\nimport { EntitlementEventBody, type EntitlementEventPayload, type EventBody } from \"./schema.js\";\n\nexport interface SlackMessage {\n text: string;\n blocks: unknown[];\n}\n\nexport function formatMessage(body: EventBody): SlackMessage {\n switch (body.type) {\n case \"entitlement.created\":\n return formatEntitlementCreated(entitlement(body));\n case \"entitlement.updated\":\n return formatEntitlementUpdated(entitlement(body));\n case \"entitlement.deleted\":\n return formatEntitlementDeleted(entitlement(body));\n case \"entitlement.expired\":\n return formatEntitlementExpired(entitlement(body));\n default:\n return formatGeneric(body);\n }\n}\n\n// Throws (zod) when an entitlement.* body is missing/malformed metadata.\nfunction entitlement(body: EventBody): EntitlementEventPayload {\n return EntitlementEventBody.parse(body).entitlement;\n}\n\n// Course grants keep the familiar \"enrolled\" wording; other content types get\n// neutral \"granted access\" copy.\nfunction grantVerb(e: EntitlementEventPayload): string {\n return e.content.type === \"course\" ? \"enrolled in\" : \"granted access to\";\n}\n\nfunction formatEntitlementCreated(e: EntitlementEventPayload): SlackMessage {\n return {\n text: `✅ ${studentName(e)} ${grantVerb(e)} ${e.content.title}`,\n blocks: [header(\"✅ New entitlement\"), entitlementFields(e), context(`Granted ${e.grantedAt}`)],\n };\n}\n\nfunction formatEntitlementUpdated(e: EntitlementEventPayload): SlackMessage {\n return {\n text: `🔄 ${studentName(e)}'s access to ${e.content.title} was updated`,\n blocks: [\n header(\"🔄 Entitlement updated\"),\n entitlementFields(e),\n context(`Originally granted ${e.grantedAt}`),\n ],\n };\n}\n\nfunction formatEntitlementDeleted(e: EntitlementEventPayload): SlackMessage {\n return {\n text: `🚫 ${studentName(e)}'s access to ${e.content.title} was revoked`,\n blocks: [\n header(\"🚫 Entitlement removed\"),\n entitlementFields(e),\n context(`Originally granted ${e.grantedAt}`),\n ],\n };\n}\n\nfunction formatEntitlementExpired(e: EntitlementEventPayload): SlackMessage {\n return {\n text: `⏳ ${studentName(e)}'s access to ${e.content.title} has expired`,\n blocks: [\n header(\"⏳ Entitlement expired\"),\n entitlementFields(e),\n context(`Originally granted ${e.grantedAt}`),\n ],\n };\n}\n\n// Fallback for event types without a bespoke formatter: post the type and the\n// event's metadata as a code block (truncated to stay inside Slack's 3000-char\n// section limit).\nfunction formatGeneric(body: EventBody): SlackMessage {\n const { type, ...data } = body;\n const json = JSON.stringify(data, null, 2) ?? \"{}\";\n const truncated = json.length > 2800 ? `${json.slice(0, 2800)}\\n…` : json;\n return {\n text: `📣 ${type}`,\n blocks: [\n header(`📣 ${type}`),\n { type: \"section\", text: { type: \"mrkdwn\", text: `\\`\\`\\`${truncated}\\`\\`\\`` } },\n ],\n };\n}\n\nfunction studentName(e: EntitlementEventPayload): string {\n return `${e.firstName} ${e.lastName}`;\n}\n\nfunction header(title: string): unknown {\n return { type: \"header\", text: { type: \"plain_text\", text: title, emoji: true } };\n}\n\nfunction field(label: string, value: string): { type: \"mrkdwn\"; text: string } {\n return { type: \"mrkdwn\", text: `*${label}*\\n${value}` };\n}\n\nfunction entitlementFields(e: EntitlementEventPayload): unknown {\n const fields = [\n field(\"Student\", studentName(e)),\n field(\"Email\", e.studentEmail),\n field(contentLabel(e), e.content.title),\n field(\"Granted at\", e.grantedAt),\n ];\n if (e.expiresAt) {\n fields.push(field(\"Expires\", e.expiresAt));\n }\n return { type: \"section\", fields };\n}\n\n// \"Course\" for course grants; capitalized content type otherwise.\nfunction contentLabel(e: EntitlementEventPayload): string {\n const t = e.content.type;\n return t.charAt(0).toUpperCase() + t.slice(1);\n}\n\nfunction context(text: string): unknown {\n return { type: \"context\", elements: [{ type: \"mrkdwn\", text }] };\n}\n","// slack integration — the posting action: takes a channel and a domain-event\n// body, formats the message by event type, posts it.\nimport { z } from \"zod\";\nimport { zodAction } from \"@headless-lms/core/integrations\";\nimport { postMessage } from \"../client.js\";\nimport { formatMessage } from \"../notifications/formatters.js\";\nimport { EventBody } from \"../notifications/schema.js\";\n\nexport const postToChannel = zodAction({\n id: \"postToChannel\",\n description:\n \"Post a message to a Slack channel.\",\n input: z.object({\n channel: z\n .string()\n .min(1)\n .optional()\n .meta({\n description: \"The channel to post to; the connection's default channel when omitted.\",\n \"x-options\": { action: \"listChannels\", items: \"channels\", value: \"id\", label: \"name\" },\n }),\n body: EventBody,\n }),\n output: z.object({\n channel: z.string(),\n ts: z.string().describe(\"Slack's message timestamp.\"),\n }),\n run(ctx, input) {\n const channel = input.channel ?? (ctx.config.defaultChannel as string);\n const message = formatMessage(input.body);\n return postMessage(\n { botToken: ctx.secrets.botToken as string },\n { channel, text: message.text, blocks: message.blocks },\n );\n },\n});\n","// slack integration — channel listing for pickers (select a channel to post to).\nimport { z } from \"zod\";\nimport { zodAction } from \"@headless-lms/core/integrations\";\nimport { listChannels as fetchChannels } from \"../client.js\";\n\nexport const listChannels = zodAction({\n id: \"listChannels\",\n description:\n \"List public Slack channels the connection can see (for channel pickers). Requires the channels:read scope.\",\n input: z.object({\n cursor: z.string().optional().describe(\"Pagination cursor from a previous call.\"),\n limit: z.number().int().min(1).max(200).default(100),\n }),\n output: z.object({\n channels: z.array(z.object({ id: z.string(), name: z.string() })),\n nextCursor: z.string().optional(),\n }),\n run(ctx, input) {\n return fetchChannels({ botToken: ctx.secrets.botToken as string }, input);\n },\n});\n","// slack integration — satisfies the Integration contract; declares the config\n// shape a Slack connection carries and the actions callers can invoke. The\n// credential (bot token) is opaque to the platform; actions receive it\n// revealed, at point of use, via the ActionContext.\n//\n// Assembly only — schemas live in schemas.ts, transport in client.ts, actions\n// under actions/, and event formatting under notifications/.\nimport type { Integration } from \"@headless-lms/core/types\";\nimport { zodConfig, zodSecrets } from \"@headless-lms/core/integrations\";\nimport { SlackConfig, SlackSecrets } from \"./schemas.js\";\nimport { postToChannel } from \"./actions/post-to-channel.js\";\nimport { listChannels } from \"./actions/list-channels.js\";\n\nconst slack: Integration = {\n id: \"slack\",\n ...zodConfig(SlackConfig),\n ...zodSecrets(SlackSecrets),\n actions: [postToChannel, listChannels],\n};\n\nexport default slack;\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@headless-lms/plugin-slack",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"zod": "4.4.3",
|
|
15
|
+
"@headless-lms/core": "0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^22.10.2",
|
|
19
|
+
"tsdown": "^0.22.9",
|
|
20
|
+
"typescript": "^5.7.2",
|
|
21
|
+
"vitest": "^2.1.8"
|
|
22
|
+
},
|
|
23
|
+
"description": "Slack notifications plugin for the headless LMS",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/mdwt/headless-lms.git",
|
|
28
|
+
"directory": "plugins/slack"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/mdwt/headless-lms#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/mdwt/headless-lms/issues"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsdown",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run"
|
|
44
|
+
}
|
|
45
|
+
}
|