paperclip-plugin-mattermost 0.0.0-stage → 0.11.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Haseeb Azhar
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 CHANGED
@@ -1,3 +1,79 @@
1
- # Temporary Holding Version
1
+ # paperclip-plugin-mattermost
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost teammates. Every agent gets its own Mattermost bot, with its Paperclip portrait. DM an agent or @mention it in a channel, and it answers in the thread. Follow-ups in the thread continue the same conversation.
4
+
5
+ ## What it does
6
+
7
+ - **A bot per agent.** Bots are created, renamed and switched off as agents change (every 5 minutes). Choose all agents, or only the ones you pick.
8
+ - **You see it working.** The agent's bot reacts 👀 to your message straight away. Its reply starts as _thinking…_ and lists the steps it runs, then the answer replaces the list.
9
+ - **Files both ways.** Attach files to a message and the agent can read them. Files the agent saves are attached to its reply.
10
+ - **Choices as buttons.** An agent can offer options (`choices: A | B | C` on its last line), which appear as buttons. A click is sent back as your answer.
11
+ - **Approvals with buttons.** Every Paperclip approval is posted in an approvals channel with Approve / Reject buttons, including hires, budget overrides, board approvals and high-risk tool calls. An approval raised during a conversation also appears in that thread.
12
+ - **Task decision cards.** When an agent asks something on a task, the card is posted in the approvals channel:
13
+ - **Confirmations and suggested tasks** get Accept / Reject buttons.
14
+ - **Single-choice questions** get a button per option. Your pick is posted on the task as a comment from you, which wakes the agent.
15
+ - **Access control.** Choose who may talk to agents: everyone, or only the people you pick. Each person can be limited to some agents, and people outside Paperclip can be added by email.
16
+ - **One install, many companies.** Each Paperclip company connects its own Mattermost, with its own settings, bots and state.
17
+
18
+ ## Requirements
19
+
20
+ - **Paperclip** with plugin support. The plugin is built against a specific Paperclip commit (see `PAPERCLIP_REF` in `build.mjs`). If your server runs a different one, the settings page and the plugin's health say so. Most features keep working.
21
+ - **Mattermost** (Team Edition is fine) with bot accounts and personal access tokens enabled: *System Console → Integrations → Bot Accounts* and *Integration Management*.
22
+ - **A Mattermost System Admin personal access token.** The plugin uses it to create and manage the agent bots. A bot token won't work, because Mattermost doesn't let bots create bots.
23
+ - For buttons: **Mattermost must be able to reach Paperclip** at a public address.
24
+ - For files: the plugin and your agents must run on the **same machine or container**, as in the standard Docker setup, because files are exchanged through `/tmp/paperclip-mattermost`.
25
+
26
+ ## Install
27
+
28
+ As a Paperclip instance admin:
29
+
30
+ ```bash
31
+ paperclipai plugin install paperclip-plugin-mattermost
32
+ ```
33
+
34
+ Or install it from **Settings → Plugins** in Paperclip.
35
+
36
+ ## Set up (per company)
37
+
38
+ 1. **In Mattermost**, signed in as a System Admin, go to **Profile → Security → Personal Access Tokens → Create Token** and copy the token.
39
+ 2. **In Paperclip**, open the company, then **Settings → Secrets**, and save the token as a secret.
40
+ 3. **Settings → Plugins → Mattermost**, then fill in:
41
+ - **Mattermost URL**, e.g. `https://chat.example.com`.
42
+ - **Mattermost admin token**: pick the secret from step 2.
43
+ - **Paperclip public URL**: filled in from Paperclip's own settings. The page checks it by sending a test click through it.
44
+ - **Approvals channel**: where approvals and decision cards are posted.
45
+ - **Agents in Mattermost**: all agents, or the ones you pick.
46
+ - **Who can talk to agents**: everyone, or the people you pick, each with all agents or some.
47
+ - **Approvers**: who may decide approvals and cards from Mattermost. Their clicks count as their Paperclip decisions, matched to Mattermost by email.
48
+ 4. **Save.** Within a minute every agent appears as a bot in your Mattermost teams.
49
+
50
+ ## Security
51
+
52
+ - **Every button is signed** with a key derived from the company's admin token. A forged or altered click is refused, and rotating the token voids old buttons.
53
+ - **Decisions are made as real people.** Approvals, card answers and question comments are recorded as the clicker's Paperclip user. Paperclip itself checks that user is an active member allowed to decide.
54
+ - **Agent bot tokens** are minted at startup, held in memory only, and revoked on the next start.
55
+ - **The admin token** has System Admin rights in Mattermost. Keep it in a Paperclip secret, and rotate it if it leaks.
56
+
57
+ ## Limits
58
+
59
+ - **Paperclip doesn't notify plugins of new decision cards**, so open tasks are checked every minute. A card can take up to a minute to appear.
60
+ - **Multi-choice questions** and free-text "Other" answers are answered in Paperclip; the card links there.
61
+ - **Mattermost can't hide bots** from particular people. Someone without access can still DM an agent bot; they get a private note and the agent doesn't run.
62
+ - **Agents with a custom uploaded portrait** keep the default bot picture, because those images need a Paperclip login to download.
63
+ - **Live progress** shows steps for agents run through ACPX (Claude Code, and others Paperclip runs that way). Other adapters show only _thinking…_.
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ npm install
69
+ node build.mjs # bundles the Paperclip SDK at PAPERCLIP_REF (cloned into .paperclip-src/)
70
+ npm test # unit tests
71
+ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
72
+ ```
73
+
74
+ - `scripts/deploy-remote.sh` copies a build to a Paperclip running in Docker on another machine over ssh, and reloads it with Paperclip's CLI.
75
+ - `test/live-mattermost.ts` and `test/live-roster.ts` run against a real Mattermost.
76
+
77
+ ## License
78
+
79
+ MIT
@@ -0,0 +1,95 @@
1
+ // src/manifest.ts
2
+ var manifest = {
3
+ id: "mattermost-bridge",
4
+ apiVersion: 1,
5
+ version: "0.11.0",
6
+ displayName: "Mattermost",
7
+ description: "Every agent becomes its own Mattermost bot. DM an agent or mention it in a channel and it answers in the thread, showing its steps as it works; follow-ups continue the conversation. Paperclip approvals arrive with Approve / Reject buttons.",
8
+ author: "Haseeb Azhar",
9
+ categories: ["connector"],
10
+ capabilities: [
11
+ "agents.read",
12
+ "agent.sessions.create",
13
+ "agent.sessions.send",
14
+ "agent.sessions.list",
15
+ "agent.sessions.close",
16
+ "secrets.read-ref",
17
+ "plugin.state.read",
18
+ "plugin.state.write",
19
+ "http.outbound",
20
+ "events.subscribe",
21
+ "webhooks.receive",
22
+ "approvals.read",
23
+ "approvals.respond",
24
+ "instance.settings.register",
25
+ "issues.read",
26
+ "issue.interactions.read",
27
+ "issue.interactions.respond",
28
+ "issue.comments.create",
29
+ "issue.comments.create_human_attributed"
30
+ ],
31
+ entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
32
+ // Replaces the generated form: approvers picked from members, URL detected and checked.
33
+ ui: { slots: [{ type: "settingsPage", id: "mattermost-settings", displayName: "Mattermost", exportName: "SettingsPage" }] },
34
+ webhooks: [
35
+ { endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
36
+ ],
37
+ instanceConfigSchema: {
38
+ type: "object",
39
+ required: ["mattermostUrl", "botToken"],
40
+ properties: {
41
+ mattermostUrl: {
42
+ type: "string",
43
+ title: "Mattermost URL",
44
+ description: "Your Mattermost address, for example https://chat.example.com"
45
+ },
46
+ botToken: {
47
+ // No "type": the picker saves a { type: "secret_ref", secretId } object and the
48
+ // server validates with plain Ajv, so type "string" rejects it. The form picks
49
+ // the secret picker from `format` alone.
50
+ format: "secret-ref",
51
+ title: "Mattermost admin token",
52
+ description: "A Paperclip secret holding a personal access token of a Mattermost System Admin user (Profile, Security, Personal Access Tokens). Bot accounts cannot be used: Mattermost does not let bots create bots. It creates one bot per agent, keeps them in sync, and listens in every public channel of its teams."
53
+ },
54
+ paperclipUrl: {
55
+ type: "string",
56
+ title: "Paperclip public URL",
57
+ description: "This Paperclip's address as Mattermost can reach it, for example https://paperclip.example.com. Turns on approval buttons; leave empty to turn them off."
58
+ },
59
+ approvalsChannel: {
60
+ type: "string",
61
+ title: "Approvals channel",
62
+ description: "Channel name (as in its URL, e.g. approvals) where every approval is posted; one raised during a Mattermost conversation also appears in that thread."
63
+ },
64
+ agentsMode: {
65
+ type: "string",
66
+ enum: ["all", "selected"],
67
+ title: "Agents in Mattermost",
68
+ description: "all: every agent gets a bot, new ones included. selected: only the agents listed in `agents`."
69
+ },
70
+ agents: { type: "array", title: "Selected agents", items: { type: "string" } },
71
+ access: {
72
+ type: "string",
73
+ enum: ["everyone", "selected"],
74
+ title: "Who can talk to agents",
75
+ description: "everyone: anyone in this Mattermost. selected: only the people in `allowedUsers`, matched by Mattermost email."
76
+ },
77
+ allowedUsers: {
78
+ type: "array",
79
+ title: "People who can talk to agents",
80
+ description: "Each person by email, with `agents` listing the agent ids they may talk to (absent: all agents). People outside Paperclip have no userId.",
81
+ items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
82
+ },
83
+ approvers: {
84
+ type: "array",
85
+ title: "Approvers",
86
+ description: "Who may decide approvals from Mattermost, picked on the settings page: their click is decided as their Paperclip user, matched by Mattermost email.",
87
+ items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" } }, required: ["userId", "email"] }
88
+ }
89
+ }
90
+ }
91
+ };
92
+ var manifest_default = manifest;
93
+ export {
94
+ manifest_default as default
95
+ };
@@ -0,0 +1,344 @@
1
+ // src/ui/index.tsx
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { usePluginAction, usePluginData } from "@paperclipai/plugin-sdk/ui";
4
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
5
+ var PLUGIN = "mattermost-bridge";
6
+ async function api(path, init) {
7
+ const r = await fetch(path, { credentials: "include", ...init, headers: { "content-type": "application/json", ...init?.headers ?? {} } });
8
+ if (!r.ok) throw new Error(await r.text() || `HTTP ${r.status}`);
9
+ return await r.json();
10
+ }
11
+ var s = {
12
+ field: { display: "grid", gap: 6, marginBottom: 22 },
13
+ label: { fontWeight: 600, fontSize: 14 },
14
+ help: { fontSize: 12.5, opacity: 0.65, lineHeight: 1.45 },
15
+ input: { width: "100%", boxSizing: "border-box", padding: "8px 10px", borderRadius: 6, border: "1px solid rgba(127,127,127,.35)", background: "transparent", color: "inherit", font: "inherit" },
16
+ row: { display: "flex", gap: 8, alignItems: "center" },
17
+ button: { padding: "7px 14px", borderRadius: 6, border: "1px solid rgba(127,127,127,.35)", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer", whiteSpace: "nowrap" },
18
+ primary: { padding: "8px 18px", borderRadius: 6, border: "none", background: "#e8e8e8", color: "#111", font: "inherit", fontWeight: 600, cursor: "pointer" },
19
+ member: { display: "flex", gap: 10, alignItems: "center", padding: "8px 10px", borderRadius: 6, border: "1px solid rgba(127,127,127,.2)", cursor: "pointer" },
20
+ ok: { color: "#3db887" },
21
+ bad: { color: "#e5484d" }
22
+ };
23
+ function Field({ label, help, error, children }) {
24
+ return /* @__PURE__ */ jsxs("div", { style: s.field, children: [
25
+ /* @__PURE__ */ jsx("div", { style: s.label, children: label }),
26
+ children,
27
+ error ? /* @__PURE__ */ jsx("div", { style: { ...s.bad, fontSize: 13 }, children: error }) : null,
28
+ help ? /* @__PURE__ */ jsx("div", { style: s.help, children: help }) : null
29
+ ] });
30
+ }
31
+ function flip(set, id) {
32
+ const next = new Set(set);
33
+ if (next.has(id)) next.delete(id);
34
+ else next.add(id);
35
+ return next;
36
+ }
37
+ function Modes({ value, options, onChange }) {
38
+ return /* @__PURE__ */ jsx("div", { style: { display: "flex", gap: 16, flexWrap: "wrap" }, children: options.map(([v, label]) => /* @__PURE__ */ jsxs("label", { style: { display: "flex", gap: 6, alignItems: "center", cursor: "pointer" }, children: [
39
+ /* @__PURE__ */ jsx("input", { type: "radio", checked: value === v, onChange: () => onChange(v) }),
40
+ " ",
41
+ label
42
+ ] }, v)) });
43
+ }
44
+ function People({ members, selected, accounts, onToggle }) {
45
+ return /* @__PURE__ */ jsx("div", { style: { display: "grid", gap: 6 }, children: members.map((m) => {
46
+ const email = m.user?.email ?? "";
47
+ const account = email ? accounts?.[email.toLowerCase()] : void 0;
48
+ return /* @__PURE__ */ jsxs("label", { style: { ...s.member, ...selected.has(m.principalId) ? { borderColor: "rgba(127,127,127,.55)" } : {} }, children: [
49
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: selected.has(m.principalId), onChange: () => onToggle(m.principalId), disabled: !email }),
50
+ /* @__PURE__ */ jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [
51
+ /* @__PURE__ */ jsxs("div", { style: { fontWeight: 600 }, children: [
52
+ m.user?.name || email,
53
+ m.membershipRole ? /* @__PURE__ */ jsx("span", { style: { ...s.help, marginLeft: 8 }, children: m.membershipRole }) : null
54
+ ] }),
55
+ /* @__PURE__ */ jsx("div", { style: { ...s.help, overflow: "hidden", textOverflow: "ellipsis" }, children: email || "no email" })
56
+ ] }),
57
+ /* @__PURE__ */ jsx("div", { style: { ...s.help, ...account ? s.ok : account === null ? s.bad : {} }, children: account ? `@${account}` : account === null ? "no Mattermost account with this email" : "" })
58
+ ] }, m.principalId);
59
+ }) });
60
+ }
61
+ function grantsFrom(value) {
62
+ const map = /* @__PURE__ */ new Map();
63
+ for (const g of Array.isArray(value) ? value : []) {
64
+ if (g?.email) map.set(g.email.toLowerCase(), { userId: g.userId || void 0, email: g.email.toLowerCase(), name: g.name, agents: Array.isArray(g.agents) ? g.agents : null });
65
+ }
66
+ return map;
67
+ }
68
+ function AccessList({ members, agents, grants, accounts, onChange }) {
69
+ const [adding, setAdding] = useState("");
70
+ const memberEmails = new Set(members.map((m) => m.user?.email?.toLowerCase()).filter(Boolean));
71
+ const rows = [
72
+ ...members.filter((m) => m.user?.email).map((m) => ({ key: m.principalId, email: m.user.email.toLowerCase(), name: m.user.name || m.user.email, sub: m.membershipRole ?? "", userId: m.principalId, outside: false })),
73
+ ...[...grants.values()].filter((g) => !memberEmails.has(g.email)).map((g) => ({ key: g.email, email: g.email, name: g.name || g.email, sub: "not in Paperclip", outside: true }))
74
+ ];
75
+ const update = (email, g) => {
76
+ const next = new Map(grants);
77
+ if (g) next.set(email, g);
78
+ else next.delete(email);
79
+ onChange(next);
80
+ };
81
+ const add = () => {
82
+ const email = adding.trim().toLowerCase();
83
+ if (!/^\S+@\S+\.\S+$/.test(email) || grants.has(email)) return;
84
+ update(email, { email, agents: null });
85
+ setAdding("");
86
+ };
87
+ return /* @__PURE__ */ jsxs("div", { style: { display: "grid", gap: 6 }, children: [
88
+ rows.map((r) => {
89
+ const g = grants.get(r.email);
90
+ const account = accounts?.[r.email];
91
+ return /* @__PURE__ */ jsxs("div", { style: { ...s.member, display: "block", cursor: "default", ...g ? { borderColor: "rgba(127,127,127,.55)" } : {} }, children: [
92
+ /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 10, alignItems: "center" }, children: [
93
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: Boolean(g), onChange: () => update(r.email, g ? null : { userId: r.userId, email: r.email, name: r.name, agents: null }) }),
94
+ /* @__PURE__ */ jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [
95
+ /* @__PURE__ */ jsxs("div", { style: { fontWeight: 600 }, children: [
96
+ r.name,
97
+ r.sub ? /* @__PURE__ */ jsx("span", { style: { ...s.help, marginLeft: 8 }, children: r.sub }) : null
98
+ ] }),
99
+ /* @__PURE__ */ jsx("div", { style: { ...s.help, overflow: "hidden", textOverflow: "ellipsis" }, children: r.email })
100
+ ] }),
101
+ /* @__PURE__ */ jsx("div", { style: { ...s.help, ...account ? s.ok : account === null ? s.bad : {} }, children: account ? `@${account}` : account === null ? "no Mattermost account with this email" : "" }),
102
+ g ? /* @__PURE__ */ jsxs("select", { style: { ...s.input, width: "auto" }, value: g.agents ? "some" : "all", onChange: (e) => update(r.email, { ...g, agents: e.target.value === "all" ? null : [] }), children: [
103
+ /* @__PURE__ */ jsx("option", { value: "all", children: "All agents" }),
104
+ /* @__PURE__ */ jsx("option", { value: "some", children: "Only some agents" })
105
+ ] }) : null,
106
+ r.outside ? /* @__PURE__ */ jsx("button", { style: { ...s.button, padding: "4px 9px" }, title: "Remove", onClick: () => update(r.email, null), children: "\u2715" }) : null
107
+ ] }),
108
+ g?.agents ? /* @__PURE__ */ jsx("div", { style: { display: "flex", flexWrap: "wrap", gap: 6, marginTop: 8, paddingLeft: 26 }, children: agents.map((a) => {
109
+ const on = g.agents.includes(a.id);
110
+ return /* @__PURE__ */ jsxs("label", { style: { display: "flex", gap: 5, alignItems: "center", padding: "3px 8px", borderRadius: 999, border: `1px solid rgba(127,127,127,${on ? ".7" : ".25"})`, cursor: "pointer", fontSize: 13 }, children: [
111
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: on, onChange: () => update(r.email, { ...g, agents: on ? g.agents.filter((x) => x !== a.id) : [...g.agents, a.id] }) }),
112
+ a.name
113
+ ] }, a.id);
114
+ }) }) : null
115
+ ] }, r.key);
116
+ }),
117
+ /* @__PURE__ */ jsxs("div", { style: s.row, children: [
118
+ /* @__PURE__ */ jsx("input", { style: s.input, value: adding, onChange: (e) => setAdding(e.target.value), onKeyDown: (e) => {
119
+ if (e.key === "Enter") add();
120
+ }, placeholder: "Add someone outside Paperclip by email (a client, a contractor)\u2026" }),
121
+ /* @__PURE__ */ jsx("button", { style: s.button, onClick: add, children: "Add" })
122
+ ] })
123
+ ] });
124
+ }
125
+ function validate(c) {
126
+ const errors = {};
127
+ const url = String(c.mattermostUrl ?? "").trim();
128
+ if (!url) errors.mattermostUrl = "Enter your Mattermost address.";
129
+ else if (!/^https?:\/\/[^\s/]+/.test(url)) errors.mattermostUrl = "Enter a full address starting with https://";
130
+ if (!c.botToken?.secretId) errors.botToken = "Choose the secret that holds the Mattermost admin token.";
131
+ return errors;
132
+ }
133
+ function saveErrorText(raw) {
134
+ try {
135
+ const j = JSON.parse(raw);
136
+ const parts = (j.fieldErrors ?? []).map((f) => {
137
+ const missing = f.message?.match(/required property '(\w+)'/)?.[1];
138
+ if (missing === "botToken") return "the Mattermost admin token is missing";
139
+ if (missing === "mattermostUrl") return "the Mattermost URL is missing";
140
+ return `${f.field && f.field !== "/" ? f.field.replace(/^\//, "") + ": " : ""}${f.message ?? "invalid"}`;
141
+ });
142
+ if (parts.length) return `Not saved: ${parts.join("; ")}.`;
143
+ if (j.error) return `Not saved: ${j.error}`;
144
+ } catch {
145
+ }
146
+ return `Not saved: ${raw.slice(0, 300)}`;
147
+ }
148
+ function savedApproverIds(value) {
149
+ if (Array.isArray(value)) return new Set(value.map((a) => a.userId));
150
+ const ids = /* @__PURE__ */ new Set();
151
+ for (const line of String(value ?? "").split(/\r?\n/)) {
152
+ const m = line.replace(/#.*/, "").trim().match(/^\S+@\S+\s*[=:]\s*(\S+)$/);
153
+ if (m) ids.add(m[1]);
154
+ }
155
+ return ids;
156
+ }
157
+ function SettingsPage({ context }) {
158
+ const companyId = context.companyId;
159
+ const [config, setConfig] = useState(null);
160
+ const [members, setMembers] = useState([]);
161
+ const [agents, setAgents] = useState([]);
162
+ const [agentsMode, setAgentsMode] = useState("all");
163
+ const [pickedAgents, setPickedAgents] = useState(/* @__PURE__ */ new Set());
164
+ const [access, setAccess] = useState("everyone");
165
+ const [grants, setGrants] = useState(/* @__PURE__ */ new Map());
166
+ const [secrets, setSecrets] = useState([]);
167
+ const [selected, setSelected] = useState(/* @__PURE__ */ new Set());
168
+ const [loadError, setLoadError] = useState(null);
169
+ const [saveError, setSaveError] = useState(null);
170
+ const [fieldErrors, setFieldErrors] = useState({});
171
+ const [saving, setSaving] = useState(false);
172
+ const [saved, setSaved] = useState(false);
173
+ const [check, setCheck] = useState(null);
174
+ const verify = usePluginAction("verify-url");
175
+ useEffect(() => {
176
+ if (!companyId) return;
177
+ Promise.all([
178
+ api(`/api/plugins/${PLUGIN}/config?companyId=${companyId}`),
179
+ api(`/api/companies/${companyId}/members`).then((r) => r.members).catch(() => api(`/api/companies/${companyId}/user-directory`).then((r) => r.users.map((u) => ({ ...u, principalType: "user", membershipRole: null })))),
180
+ api(`/api/companies/${companyId}/secrets`).catch(() => []),
181
+ api(`/api/companies/${companyId}/agents`).then((r) => Array.isArray(r) ? r : r.agents).catch(() => [])
182
+ ]).then(([cfg, mem, sec, ags]) => {
183
+ const c = cfg?.configJson ?? {};
184
+ setAgents(ags.filter((a) => a.status !== "terminated"));
185
+ setAgentsMode(c.agentsMode === "selected" ? "selected" : "all");
186
+ setPickedAgents(new Set(Array.isArray(c.agents) ? c.agents : []));
187
+ setAccess(c.access === "selected" ? "selected" : "everyone");
188
+ setGrants(grantsFrom(c.allowedUsers));
189
+ setConfig(c);
190
+ setSelected(savedApproverIds(c.approvers));
191
+ setMembers(mem.filter((m) => m.principalType === "user" && m.status === "active" && m.user && m.membershipRole !== "viewer"));
192
+ setSecrets(sec.filter((x) => !x.deletedAt));
193
+ }).catch((e) => setLoadError(String(e?.message ?? e)));
194
+ }, [companyId]);
195
+ const outsiderKey = [...grants.keys()].sort().join(",");
196
+ const emails = useMemo(() => [.../* @__PURE__ */ new Set([...members.map((m) => m.user?.email?.toLowerCase()).filter((e) => Boolean(e)), ...outsiderKey.split(",").filter(Boolean)])], [members, outsiderKey]);
197
+ const setup = usePluginData("setup", { companyId, emails });
198
+ const detected = setup.data?.detectedUrl ?? null;
199
+ const url = typeof config?.paperclipUrl === "string" && config.paperclipUrl ? config.paperclipUrl : detected ?? "";
200
+ useEffect(() => {
201
+ if (!url || check?.url === url) return;
202
+ const t = setTimeout(() => {
203
+ setCheck({ url, ok: null, detail: "Checking that Mattermost can reach this address\u2026" });
204
+ verify({ url }).then((r) => {
205
+ const res = r;
206
+ setCheck((cur) => cur?.url === url ? { url, ...res } : cur);
207
+ }).catch((e) => setCheck({ url, ok: false, detail: String(e?.message ?? e) }));
208
+ }, 700);
209
+ return () => clearTimeout(t);
210
+ }, [url]);
211
+ if (!companyId) return /* @__PURE__ */ jsx("div", { style: s.help, children: "Open this page from a company to configure it." });
212
+ if (loadError) return /* @__PURE__ */ jsxs("div", { style: s.bad, children: [
213
+ "Could not load the settings. Reload the page; if it keeps failing, check the plugin's Status tab. (",
214
+ loadError.slice(0, 200),
215
+ ")"
216
+ ] });
217
+ if (!config) return /* @__PURE__ */ jsx("div", { style: s.help, children: "Loading\u2026" });
218
+ const set = (patch) => {
219
+ setSaved(false);
220
+ setSaveError(null);
221
+ setFieldErrors((fe) => {
222
+ const n = { ...fe };
223
+ for (const k of Object.keys(patch)) delete n[k];
224
+ return n;
225
+ });
226
+ setConfig({ ...config, ...patch });
227
+ };
228
+ const token = config.botToken;
229
+ const toggle = (id) => {
230
+ setSaved(false);
231
+ const next = new Set(selected);
232
+ if (next.has(id)) next.delete(id);
233
+ else next.add(id);
234
+ setSelected(next);
235
+ };
236
+ async function save() {
237
+ setSaveError(null);
238
+ const errors = validate(config);
239
+ if (agentsMode === "selected" && !pickedAgents.size) errors.agents = "Tick at least one agent, or choose All agents.";
240
+ if (access === "selected" && !grants.size) errors.access = "Tick at least one person, or choose Everyone.";
241
+ const empty = [...grants.values()].find((g) => g.agents && !g.agents.length);
242
+ if (access === "selected" && empty) errors.access = `Pick at least one agent for ${empty.name || empty.email}, or give them All agents.`;
243
+ setFieldErrors(errors);
244
+ if (Object.keys(errors).length) {
245
+ setSaveError("Fix the fields marked above, then save.");
246
+ return;
247
+ }
248
+ setSaving(true);
249
+ try {
250
+ const approvers = members.filter((m) => selected.has(m.principalId) && m.user?.email).map((m) => ({ userId: m.principalId, email: m.user.email, name: m.user.name ?? void 0 }));
251
+ const next = { ...config, paperclipUrl: url, approvers, agentsMode, agents: [...pickedAgents], access, allowedUsers: [...grants.values()] };
252
+ await api(`/api/plugins/${PLUGIN}/config`, { method: "POST", body: JSON.stringify({ companyId, configJson: next }) });
253
+ setConfig(next);
254
+ setSaved(true);
255
+ } catch (e) {
256
+ setSaveError(saveErrorText(String(e?.message ?? e)));
257
+ } finally {
258
+ setSaving(false);
259
+ }
260
+ }
261
+ return /* @__PURE__ */ jsxs("div", { style: { maxWidth: 760 }, children: [
262
+ /* @__PURE__ */ jsxs("div", { style: { ...s.help, marginBottom: 18, ...setup.data?.connected ? s.ok : {} }, children: [
263
+ setup.data?.connected ? `\u25CF Connected: ${setup.data.status ?? ""}` : setup.data?.status ? `\u25CF ${setup.data.status}` : "\u25CF Not connected yet: fill in the Mattermost URL and admin token, then save.",
264
+ setup.data?.versionNote ? /* @__PURE__ */ jsxs("div", { style: { color: "#c98a00", marginTop: 4 }, children: [
265
+ "Note: this plugin was ",
266
+ setup.data.versionNote,
267
+ "."
268
+ ] }) : null
269
+ ] }),
270
+ /* @__PURE__ */ jsx(Field, { label: "Mattermost URL", error: fieldErrors.mattermostUrl, help: "Your Mattermost address, for example https://chat.example.com", children: /* @__PURE__ */ jsx("input", { style: s.input, value: String(config.mattermostUrl ?? ""), onChange: (e) => set({ mattermostUrl: e.target.value }), placeholder: "https://chat.example.com" }) }),
271
+ /* @__PURE__ */ jsx(Field, { label: "Mattermost admin token", error: fieldErrors.botToken, help: "A Paperclip secret holding a personal access token of a Mattermost System Admin (Profile \u2192 Security \u2192 Personal Access Tokens). It creates one bot per agent and keeps them in sync.", children: /* @__PURE__ */ jsxs("select", { style: s.input, value: token?.secretId ?? "", onChange: (e) => set({ botToken: e.target.value ? { type: "secret_ref", version: "latest", secretId: e.target.value } : void 0 }), children: [
272
+ /* @__PURE__ */ jsx("option", { value: "", children: "Choose a secret\u2026" }),
273
+ secrets.map((x) => /* @__PURE__ */ jsx("option", { value: x.id, children: x.name || x.key || x.id }, x.id))
274
+ ] }) }),
275
+ /* @__PURE__ */ jsx(Field, { label: "Paperclip public URL", help: /* @__PURE__ */ jsxs(Fragment, { children: [
276
+ "Where Mattermost sends Approve / Reject clicks. Filled in from Paperclip's own settings; change it only if Mattermost reaches Paperclip at another address.",
277
+ check ? /* @__PURE__ */ jsxs("div", { style: { marginTop: 6, ...check.ok === true ? s.ok : check.ok === false ? s.bad : {} }, children: [
278
+ check.ok === true ? "\u2713 " : check.ok === false ? "\u2717 " : "",
279
+ check.detail
280
+ ] }) : null
281
+ ] }), children: /* @__PURE__ */ jsxs("div", { style: s.row, children: [
282
+ /* @__PURE__ */ jsx("input", { style: s.input, value: url, onChange: (e) => set({ paperclipUrl: e.target.value.trim() }), placeholder: detected ?? "https://paperclip.example.com" }),
283
+ detected && url !== detected ? /* @__PURE__ */ jsxs("button", { style: s.button, onClick: () => set({ paperclipUrl: detected }), children: [
284
+ "Use ",
285
+ detected
286
+ ] }) : null
287
+ ] }) }),
288
+ /* @__PURE__ */ jsx(Field, { label: "Approvals channel", help: "Every approval, and every decision card agents raise on tasks, is posted here. An approval raised during a Mattermost conversation also appears in that thread; deciding on either card updates both.", children: setup.data?.channels.length ? /* @__PURE__ */ jsxs("select", { style: s.input, value: String(config.approvalsChannel ?? ""), onChange: (e) => set({ approvalsChannel: e.target.value }), children: [
289
+ /* @__PURE__ */ jsx("option", { value: "", children: "None: only in the agent's thread" }),
290
+ setup.data.channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
291
+ ] }) : /* @__PURE__ */ jsx("input", { style: s.input, value: String(config.approvalsChannel ?? ""), onChange: (e) => set({ approvalsChannel: e.target.value }), placeholder: "channel name, e.g. approvals" }) }),
292
+ /* @__PURE__ */ jsxs(Field, { label: "Agents in Mattermost", error: fieldErrors.agents, help: "Each agent shown here gets its own Mattermost bot. Unticking an agent switches its bot off but keeps it, with its history, for when you tick it again.", children: [
293
+ /* @__PURE__ */ jsx(
294
+ Modes,
295
+ {
296
+ value: agentsMode,
297
+ onChange: (v) => {
298
+ setAgentsMode(v);
299
+ setSaved(false);
300
+ },
301
+ options: [["all", "All agents (new agents join automatically)"], ["selected", "Only the agents I pick"]]
302
+ }
303
+ ),
304
+ agentsMode === "selected" ? /* @__PURE__ */ jsx("div", { style: { display: "grid", gap: 6 }, children: agents.map((a) => /* @__PURE__ */ jsxs("label", { style: { ...s.member, ...pickedAgents.has(a.id) ? { borderColor: "rgba(127,127,127,.55)" } : {} }, children: [
305
+ /* @__PURE__ */ jsx("input", { type: "checkbox", checked: pickedAgents.has(a.id), onChange: () => {
306
+ setPickedAgents(flip(pickedAgents, a.id));
307
+ setSaved(false);
308
+ } }),
309
+ a.avatarUrl ? /* @__PURE__ */ jsx("img", { src: a.avatarUrl, alt: "", width: 28, height: 28, style: { borderRadius: 6 } }) : null,
310
+ /* @__PURE__ */ jsxs("div", { style: { flex: 1, minWidth: 0 }, children: [
311
+ /* @__PURE__ */ jsx("div", { style: { fontWeight: 600 }, children: a.name }),
312
+ a.title ? /* @__PURE__ */ jsx("div", { style: s.help, children: a.title }) : null
313
+ ] })
314
+ ] }, a.id)) }) : null
315
+ ] }),
316
+ /* @__PURE__ */ jsxs(Field, { label: "Who can talk to agents", error: fieldErrors.access, help: "Tick who may talk to agents, and for each person whether that is all agents or only some. Anyone else, or anyone messaging an agent outside their list, gets a private note and the agent is not run. People are matched to Mattermost by email; add people outside Paperclip by their email.", children: [
317
+ /* @__PURE__ */ jsx(
318
+ Modes,
319
+ {
320
+ value: access,
321
+ onChange: (v) => {
322
+ setAccess(v);
323
+ setSaved(false);
324
+ },
325
+ options: [["everyone", "Everyone in this Mattermost"], ["selected", "Only the people I pick"]]
326
+ }
327
+ ),
328
+ access === "selected" ? /* @__PURE__ */ jsx(AccessList, { members, agents, grants, accounts: setup.data?.accounts, onChange: (next) => {
329
+ setGrants(next);
330
+ setSaved(false);
331
+ setFieldErrors((fe) => ({ ...fe, access: void 0 }));
332
+ } }) : null
333
+ ] }),
334
+ /* @__PURE__ */ jsx(Field, { label: "Approvers", help: "Who may Approve / Reject from Mattermost. Their click counts as their Paperclip decision; they are matched to Mattermost by email.", children: /* @__PURE__ */ jsx(People, { members, selected, accounts: setup.data?.accounts, onToggle: toggle }) }),
335
+ /* @__PURE__ */ jsxs("div", { style: s.row, children: [
336
+ /* @__PURE__ */ jsx("button", { style: s.primary, onClick: save, disabled: saving, children: saving ? "Saving\u2026" : "Save" }),
337
+ saved ? /* @__PURE__ */ jsx("span", { style: s.ok, children: "Saved" }) : null,
338
+ saveError ? /* @__PURE__ */ jsx("span", { style: s.bad, children: saveError }) : null
339
+ ] })
340
+ ] });
341
+ }
342
+ export {
343
+ SettingsPage
344
+ };