@fezchat/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/README.md +36 -0
- package/app-manifest.json +15 -0
- package/dist/gui.js +99 -0
- package/dist/headless.js +18293 -0
- package/package.json +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Slack → Fez → the same Slack thread
|
|
2
|
+
|
|
3
|
+
An explicit mention of your custom Slack app by an approved user becomes a task for one selected Fez agent. The bridge acknowledges the task, posts a generic progress message when that agent replies to the request, and returns its correlated terminal success or error to the original Slack thread. Ordinary messages, bot messages, other users/channels/workspaces, and shared Slack Connect channels do not start work. Follow-up app mentions in the same Slack thread stay in the same Fez thread.
|
|
4
|
+
|
|
5
|
+
## Set up
|
|
6
|
+
|
|
7
|
+
1. Create a custom app in your Slack workspace using [app-manifest.json](./app-manifest.json). Name its bot `fez`. This is a custom Socket Mode app; this extension does not ship a centrally hosted OAuth service.
|
|
8
|
+
2. In **Basic Information → App-Level Tokens**, generate an `xapp-…` token with **connections:write**. The manifest enables Socket Mode and subscribes to `app_mention` with the bot scopes **app_mentions:read** and **chat:write**. Install the app to your workspace and copy its **Bot User OAuth Token** (`xoxb-…`). Workspace administrator approval may be required by your Slack policy.
|
|
9
|
+
3. Invite `@fez` to the one Slack channel you want to use. Copy the workspace ID (`T…`), channel ID (`C…`, or `G…` for a private channel), and allowed users' member IDs (`U…`/`W…`). An empty allowed-user list never grants access.
|
|
10
|
+
4. In Fez's **Slack** extension settings, enter those IDs, select an existing Fez channel and your attested agent, and paste the two tokens. Enable the bridge and save. Tokens are written to the macOS Keychain service `fez-skill-env`, accounts `fez-slack.bot_token` and `fez-slack.app_token`; the settings panel has no read-token operation. Settings are self-encrypted on the relay.
|
|
11
|
+
5. Keep the local Fez sentinel running. Only its scheduled task opens the socket; opening the TUI or settings panel does not. It checks settings every minute and subscribes to changes. The selected agent must be owner-attested, a workspace member, and unbanned. The owner must also remain a member with permission to submit work.
|
|
12
|
+
6. From an allowed Slack account, mention the app in the configured channel, for example `@fez review the proposed API`. For a follow-up, mention it again inside the original Slack thread. The agent must use Fez's `complete_work` result operation for the final answer to return.
|
|
13
|
+
|
|
14
|
+
Disable and save to stop the connection and cancel pending exports. Every settings save creates a new revision and cancels old pending work, including changes made while the sentinel was offline; it cannot undo tasks already published to Fez or posts Slack has already accepted. Re-enable after choosing new settings to accept new mentions. Revoke the Slack tokens in Slack to disconnect the app permanently.
|
|
15
|
+
|
|
16
|
+
## Delivery and boundaries
|
|
17
|
+
|
|
18
|
+
Incoming envelopes are acknowledged after a durable local journal write, before agent work. Replayed Slack event IDs do not create a second Fez task. Signed Fez requests are persisted before publication, so a retry after an ambiguous publish uses the same Nostr event ID. State and Slack/Fez thread correlations survive restart, scoped to the owner's pubkey, relay, Slack workspace, and Slack channel. Already accepted pending tasks can resume; unseen events older than five minutes or from before the current bridge startup are ignored. No Slack history is fetched or imported. API rate limits honor Slack’s Retry-After cooldown without blocking the sentinel.
|
|
19
|
+
|
|
20
|
+
The agent's `fez_complete_work.summary` contains the actual answer shown in Slack, not a narration of its activity. Successful replies show that answer directly; errors are prefixed with `Failed:`. Terminal replies include up to eight validated HTTPS artifact URLs, with a note to open Fez for any additional artifacts. Only signature-verified replies from the assigned agent with matching channel, thread, request, recipient and terminal status are exported as results. Progress is a fixed status sentence, not arbitrary conversation text. Imported text has Fez mention syntax neutralized to prevent extra owner-signed summons. Answers use Slack's native Markdown blocks for formatting and code. Messages over Slack's 12,000-character Markdown limit or containing Slack mention syntax retain plain-text rendering; explicit mention controls are escaped. Automatic mentions and link/media unfurls remain disabled.
|
|
21
|
+
|
|
22
|
+
A local journal cannot make Slack's HTTP acceptance and a filesystem write atomic. Posts use a stable `client_msg_id`; after a connection failure, a Slack reply may be repeated if Slack does not deduplicate the retry. This never creates another Fez task. Run one sentinel for a given binding; state is local to that machine. The journal retains event IDs and correlations indefinitely; remove the extension's local data only when deliberately resetting this history.
|
|
23
|
+
|
|
24
|
+
This first version binds one Slack workspace/channel to one Fez channel/agent and reads credentials from the macOS keychain. It does not implement Slack OAuth distribution, DMs, history search, arbitrary channel mirroring, or automatic agent selection.
|
|
25
|
+
|
|
26
|
+
## Development
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
npm run build --prefix packages/fez-slack
|
|
30
|
+
npm run check --prefix packages/fez-slack
|
|
31
|
+
npm exec --prefix packages/fez-evals -- vitest --run --root packages/fez-evals tests/slack-bridge.test.ts tests/slack-gui.test.ts
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Tests use mocked Slack transport and local signed Nostr fixtures; they do not connect accounts or send live Slack messages. The Node bundle includes the existing `ws` dependency and the GUI shares the host's React in the isolated settings runtime.
|
|
35
|
+
|
|
36
|
+
Protocol references: [Socket Mode](https://docs.slack.dev/apis/events-api/using-socket-mode/), [app mentions](https://docs.slack.dev/reference/events/app_mention/), [posting in a thread](https://docs.slack.dev/reference/methods/chat.postMessage/).
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"display_information": {
|
|
3
|
+
"name": "Fez",
|
|
4
|
+
"description": "Ask your Fez agent from an approved Slack channel",
|
|
5
|
+
"background_color": "#252525"
|
|
6
|
+
},
|
|
7
|
+
"features": { "bot_user": { "display_name": "fez", "always_online": false } },
|
|
8
|
+
"oauth_config": { "scopes": { "bot": ["app_mentions:read", "chat:write"] } },
|
|
9
|
+
"settings": {
|
|
10
|
+
"socket_mode_enabled": true,
|
|
11
|
+
"event_subscriptions": { "bot_events": ["app_mention"] },
|
|
12
|
+
"org_deploy_enabled": false,
|
|
13
|
+
"token_rotation_enabled": false
|
|
14
|
+
}
|
|
15
|
+
}
|
package/dist/gui.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __fezExt = (() => {
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/gui.tsx
|
|
22
|
+
var gui_exports = {};
|
|
23
|
+
__export(gui_exports, {
|
|
24
|
+
default: () => activate
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// src/config.ts
|
|
28
|
+
var EMPTY = { revision: "", enabled: false, teamId: "", channelId: "", allowedUsers: [], fezChannel: "", worker: "" };
|
|
29
|
+
function parseConfig(raw) {
|
|
30
|
+
const v = raw && typeof raw === "object" ? raw : {};
|
|
31
|
+
const text = (key) => typeof v[key] === "string" ? v[key].trim() : "";
|
|
32
|
+
const config = { revision: text("revision").slice(0, 128), enabled: v.enabled === true, teamId: text("teamId"), channelId: text("channelId"), allowedUsers: Array.isArray(v.allowedUsers) ? [...new Set(v.allowedUsers.filter((id) => typeof id === "string" && /^[UW][A-Z0-9]+$/.test(id)))].sort() : [], fezChannel: text("fezChannel"), worker: text("worker") };
|
|
33
|
+
config.enabled &&= /^T[A-Z0-9]+$/.test(config.teamId) && /^[CG][A-Z0-9]+$/.test(config.channelId) && config.allowedUsers.length > 0 && !!config.fezChannel && /^[a-f0-9]{64}$/.test(config.worker);
|
|
34
|
+
return config;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// src/gui.tsx
|
|
38
|
+
function activate(api) {
|
|
39
|
+
const { client, secrets } = api, h = api.React.createElement;
|
|
40
|
+
const { useState, useEffect } = api.React;
|
|
41
|
+
if (!client || typeof client.agents !== "function" || typeof client.listChannels !== "function" || typeof client.extensionConfig !== "function" || typeof client.saveExtensionConfig !== "function") throw new Error("Update Fez to configure Slack channel and agent access");
|
|
42
|
+
const host = client;
|
|
43
|
+
function Panel() {
|
|
44
|
+
const [config, setConfig] = useState(EMPTY);
|
|
45
|
+
const [users, setUsers] = useState("");
|
|
46
|
+
const [channels, setChannels] = useState([]);
|
|
47
|
+
const [agents, setAgents] = useState([]);
|
|
48
|
+
const [bot, setBot] = useState(""), [app, setApp] = useState("");
|
|
49
|
+
const [stored, setStored] = useState(false), [loaded, setLoaded] = useState(false), [busy, setBusy] = useState(false);
|
|
50
|
+
const [notice, setNotice] = useState(""), [error, setError] = useState("");
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
let active = true;
|
|
53
|
+
void Promise.all([host.extensionConfig("fez-slack"), host.listChannels(), secrets.has("bot_token"), secrets.has("app_token")]).then(([saved, available, hasBot, hasApp]) => {
|
|
54
|
+
if (!active) return;
|
|
55
|
+
const next = parseConfig(saved);
|
|
56
|
+
setConfig(next);
|
|
57
|
+
setUsers(next.allowedUsers.join(", "));
|
|
58
|
+
setChannels(available.filter((channel) => !channel.archived));
|
|
59
|
+
setAgents([...host.agents()]);
|
|
60
|
+
setStored(hasBot && hasApp);
|
|
61
|
+
setLoaded(true);
|
|
62
|
+
}).catch(() => {
|
|
63
|
+
if (active) setError("Could not load Slack settings. Check relay and keychain access.");
|
|
64
|
+
});
|
|
65
|
+
return () => {
|
|
66
|
+
active = false;
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
async function save() {
|
|
70
|
+
setBusy(true);
|
|
71
|
+
setError("");
|
|
72
|
+
setNotice("");
|
|
73
|
+
try {
|
|
74
|
+
const allowedUsers = users.split(/[\s,]+/).filter(Boolean);
|
|
75
|
+
const next = parseConfig({ ...config, allowedUsers, revision: crypto.randomUUID() });
|
|
76
|
+
if (config.enabled && (!next.enabled || allowedUsers.some((user) => !/^[UW][A-Z0-9]+$/.test(user)) || !channels.some((channel) => channel.id === next.fezChannel) || !agents.some(([key]) => key === next.worker))) throw new Error("settings");
|
|
77
|
+
if (bot && !bot.trim().startsWith("xoxb-") || app && !app.trim().startsWith("xapp-")) throw new Error("tokens");
|
|
78
|
+
if (config.enabled && !stored && (!bot || !app)) throw new Error("tokens");
|
|
79
|
+
if (bot) await secrets.set("bot_token", bot.trim());
|
|
80
|
+
if (app) await secrets.set("app_token", app.trim());
|
|
81
|
+
await host.saveExtensionConfig("fez-slack", next);
|
|
82
|
+
if (bot && app) setStored(true);
|
|
83
|
+
setBot("");
|
|
84
|
+
setApp("");
|
|
85
|
+
setConfig(next);
|
|
86
|
+
setNotice(next.enabled ? "Saved. The local Fez sentinel connects and checks access within one minute." : "Saved. Slack bridge disabled.");
|
|
87
|
+
} catch {
|
|
88
|
+
setError("Could not save. Check IDs, allowed users, selected channel and agent, tokens, and relay access.");
|
|
89
|
+
} finally {
|
|
90
|
+
setBusy(false);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
const field = (key, title, placeholder) => /* @__PURE__ */ h("label", { style: { display: "grid", gap: 4 } }, title, /* @__PURE__ */ h("input", { value: config[key], placeholder, onChange: (event) => setConfig((previous) => ({ ...previous, [key]: event.target.value })) }));
|
|
94
|
+
return /* @__PURE__ */ h("section", { style: { display: "grid", gap: 12, maxWidth: 560 } }, /* @__PURE__ */ h("p", null, "Approved Slack users can mention your Fez app in one channel to ask the selected agent for work. Progress and results return to that Slack thread."), /* @__PURE__ */ h("fieldset", { disabled: !loaded || busy, style: { display: "grid", gap: 12, border: 0, padding: 0 } }, field("teamId", "Slack workspace ID", "T\u2026"), field("channelId", "Slack channel ID", "C\u2026"), /* @__PURE__ */ h("label", { style: { display: "grid", gap: 4 } }, "Allowed Slack user IDs", /* @__PURE__ */ h("input", { value: users, placeholder: "U\u2026, U\u2026", onChange: (event) => setUsers(event.target.value) })), /* @__PURE__ */ h("label", { style: { display: "grid", gap: 4 } }, "Fez channel", /* @__PURE__ */ h("select", { value: config.fezChannel, onChange: (event) => setConfig((previous) => ({ ...previous, fezChannel: event.target.value })) }, /* @__PURE__ */ h("option", { value: "" }, "Choose channel"), channels.map((channel) => /* @__PURE__ */ h("option", { key: channel.id, value: channel.id }, channel.name)))), /* @__PURE__ */ h("label", { style: { display: "grid", gap: 4 } }, "Fez agent", /* @__PURE__ */ h("select", { value: config.worker, onChange: (event) => setConfig((previous) => ({ ...previous, worker: event.target.value })) }, /* @__PURE__ */ h("option", { value: "" }, "Choose agent"), agents.map(([key, name]) => /* @__PURE__ */ h("option", { key, value: key }, name)))), /* @__PURE__ */ h("label", { style: { display: "grid", gap: 4 } }, "Bot token", /* @__PURE__ */ h("input", { type: "password", autoComplete: "off", value: bot, placeholder: stored ? "Stored; leave blank to keep" : "xoxb-\u2026", onChange: (event) => setBot(event.target.value) })), /* @__PURE__ */ h("label", { style: { display: "grid", gap: 4 } }, "App token", /* @__PURE__ */ h("input", { type: "password", autoComplete: "off", value: app, placeholder: stored ? "Stored; leave blank to keep" : "xapp-\u2026", onChange: (event) => setApp(event.target.value) })), /* @__PURE__ */ h("label", null, /* @__PURE__ */ h("input", { type: "checkbox", checked: config.enabled, onChange: (event) => setConfig((previous) => ({ ...previous, enabled: event.target.checked })) }), " Enable Slack bridge"), /* @__PURE__ */ h("button", { onClick: () => void save() }, busy ? "Saving\u2026" : "Save Slack settings")), /* @__PURE__ */ h("small", null, "Create a custom Slack app from this extension's app-manifest.json, enable Socket Mode, install it, and invite it to the selected channel. Tokens are stored in your local keychain and cannot be read back into this panel."), error && /* @__PURE__ */ h("p", { role: "alert" }, error), notice && /* @__PURE__ */ h("p", { role: "status" }, notice));
|
|
95
|
+
}
|
|
96
|
+
api.registerSettingsPanel("Slack", () => h(Panel), { source: "slack" });
|
|
97
|
+
}
|
|
98
|
+
return __toCommonJS(gui_exports);
|
|
99
|
+
})();
|