paperclip-plugin-mattermost 0.12.0 → 0.14.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 +5 -1
- package/dist/manifest.js +106 -2
- package/dist/ui/index.js +31 -2
- package/dist/worker.js +746 -46
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
|
|
|
13
13
|
- **Confirmations and suggested tasks** get Accept / Reject buttons.
|
|
14
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
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
|
+
- **Agents work in Mattermost themselves.** Through plugin API routes that only agents can call, each agent reads the channels its bot is in, posts, reacts, searches, and DMs the people allowed to talk to it, always as its own bot. Each company gets a Mattermost skill that tells agents how, and saving the settings adds it to every agent with a bot, so they can message people from tasks and scheduled runs too.
|
|
17
|
+
- **Agents work with agents.** An agent mentioning another agent starts it, and it answers in the thread. After a set number of agent-to-agent messages in a thread (10 by default, set in settings), they pause until a person replies.
|
|
18
|
+
- **Work channels.** For an investigation, incident or project, an agent opens a private channel (`inc-checkout-5xx`), pulls in the people and agents it needs, and ties it to a Paperclip task. Task comments and status changes show up in the channel. When the task is done or cancelled, the channel is archived and the outcome is posted back where the work was asked for.
|
|
19
|
+
- **Ask first.** Some actions wait for an approver to click Approve: `@channel`, `@all` and `@here`, public channels, and adding people to a channel the agent didn't create. Agents can't change or archive channels they didn't create, remove anyone, or touch team and server settings.
|
|
16
20
|
- **One install, many companies.** Each Paperclip company connects its own Mattermost, with its own settings, bots and state.
|
|
17
21
|
|
|
18
22
|
## Requirements
|
|
@@ -78,7 +82,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
|
|
|
78
82
|
Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
|
|
79
83
|
|
|
80
84
|
```bash
|
|
81
|
-
git tag v0.
|
|
85
|
+
git tag -a v0.14.0 -m v0.14.0 && git push origin main v0.14.0
|
|
82
86
|
```
|
|
83
87
|
|
|
84
88
|
GitHub Actions builds, tests and publishes to npm through npm trusted publishing, so no npm token is involved.
|
package/dist/manifest.js
CHANGED
|
@@ -1,8 +1,80 @@
|
|
|
1
|
+
// src/help.ts
|
|
2
|
+
var HELP = `# Working in Mattermost
|
|
3
|
+
|
|
4
|
+
You have your own Mattermost account (a bot). Everything below runs as you, in your company's
|
|
5
|
+
Mattermost, and you can only see and use channels you are a member of. Use it whenever you need
|
|
6
|
+
to reach a person or another agent in Mattermost, including from a task or a scheduled run: to
|
|
7
|
+
ping someone, DM them, or post in a channel. Call it with your Paperclip API credentials:
|
|
8
|
+
|
|
9
|
+
B="\${PAPERCLIP_API_URL%/}"; B="\${B%/api}/api/plugins/mattermost-bridge/api"
|
|
10
|
+
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$B/mm/channels"
|
|
11
|
+
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "content-type: application/json" \\
|
|
12
|
+
-d '{"person":"username","text":"..."}' "$B/mm/dm"
|
|
13
|
+
|
|
14
|
+
Answers are JSON. A 202 with "waiting_for_approval" means a person has been asked to approve;
|
|
15
|
+
tell whoever you are working with and carry on. A 403 says why something is not allowed.
|
|
16
|
+
|
|
17
|
+
## How to work
|
|
18
|
+
|
|
19
|
+
- **Pick the size of the work.** A quick question: answer in the thread. A task: create a Paperclip
|
|
20
|
+
task (assign it to yourself or delegate it) and say so in the thread with its identifier. An
|
|
21
|
+
investigation, incident or project, or anything involving several agents or more than a day:
|
|
22
|
+
create the task, then open a work channel for it (below), with the person who asked and whoever
|
|
23
|
+
you need. Name it for what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
|
|
24
|
+
- **Announce a work channel where the request came in**, in one message: what it is for, who you
|
|
25
|
+
pulled in, and that you will report back there. When the task is done or cancelled the plugin
|
|
26
|
+
archives the channel and posts the outcome back where you asked; you do not need to.
|
|
27
|
+
- **Hand work to another agent by mentioning it** (@cto) in a channel or thread it is in; invite it
|
|
28
|
+
first if it is not. Give it the context it needs in that message. It replies in the thread.
|
|
29
|
+
Mention an agent only when you want it to act: every mention starts it.
|
|
30
|
+
- **Say something before going quiet.** If an answer will take a while, post one line ("On it,
|
|
31
|
+
checking what shipped this morning") and then do the work.
|
|
32
|
+
- Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
|
|
33
|
+
approval.
|
|
34
|
+
|
|
35
|
+
## Calls
|
|
36
|
+
|
|
37
|
+
Reading (GET):
|
|
38
|
+
- /mm/channels channels you are in
|
|
39
|
+
- /mm/channels/{channel}/posts?limit=30 recent posts in a channel (oldest first)
|
|
40
|
+
- /mm/posts/{postId}/thread a whole thread
|
|
41
|
+
- /mm/channels/{channel}/members who is in a channel (people and agents)
|
|
42
|
+
- /mm/search?q=words search messages in your channels
|
|
43
|
+
- /mm/people?q=name find people and agents by name
|
|
44
|
+
- /mm/requests/{id} an approval request you made
|
|
45
|
+
|
|
46
|
+
Talking (POST, JSON body):
|
|
47
|
+
- /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
|
|
48
|
+
- /mm/react {"postId": "...", "emoji": "white_check_mark"}
|
|
49
|
+
- /mm/dm {"person": "username", "text": "..."} only people allowed to talk to you
|
|
50
|
+
|
|
51
|
+
Channels (POST, JSON body):
|
|
52
|
+
- /mm/channels {"name": "inc checkout 5xx", "purpose": "...", "people": ["username"],
|
|
53
|
+
"agents": ["cto"], "taskId": "BAD-123 or task id", "private": true}
|
|
54
|
+
Private by default. A public channel needs approval. With taskId, the channel is tied to the
|
|
55
|
+
task: its comments and status show up there, and it is archived when the task closes.
|
|
56
|
+
- /mm/channels/{channel}/invite {"people": [...], "agents": [...]} free on channels you created
|
|
57
|
+
- /mm/channels/{channel}/update {"purpose": "...", "header": "...", "name": "..."} yours only
|
|
58
|
+
- /mm/channels/{channel}/archive {} yours only
|
|
59
|
+
|
|
60
|
+
{channel} is a channel's id, its URL name (inc-checkout-5xx) or its display name.
|
|
61
|
+
`;
|
|
62
|
+
var SKILL = `---
|
|
63
|
+
name: mattermost
|
|
64
|
+
description: >
|
|
65
|
+
Use to talk to people and agents in Mattermost as your own bot: DM someone, post or reply in
|
|
66
|
+
a channel, read and search channels you are in, hand work to another agent by mentioning it,
|
|
67
|
+
and open a work channel for an investigation, incident or project tied to a Paperclip task.
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
${HELP}`;
|
|
71
|
+
|
|
1
72
|
// src/manifest.ts
|
|
73
|
+
var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent", capability: "api.routes.register" });
|
|
2
74
|
var manifest = {
|
|
3
75
|
id: "mattermost-bridge",
|
|
4
76
|
apiVersion: 1,
|
|
5
|
-
version: "0.
|
|
77
|
+
version: "0.14.0",
|
|
6
78
|
displayName: "Mattermost",
|
|
7
79
|
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
80
|
author: "Haseeb Azhar",
|
|
@@ -26,11 +98,35 @@ var manifest = {
|
|
|
26
98
|
"issue.interactions.read",
|
|
27
99
|
"issue.interactions.respond",
|
|
28
100
|
"issue.comments.create",
|
|
29
|
-
"issue.comments.create_human_attributed"
|
|
101
|
+
"issue.comments.create_human_attributed",
|
|
102
|
+
"issue.comments.read",
|
|
103
|
+
"api.routes.register",
|
|
104
|
+
"skills.managed"
|
|
30
105
|
],
|
|
31
106
|
entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
|
|
32
107
|
// Replaces the generated form: approvers picked from members, URL detected and checked.
|
|
33
108
|
ui: { slots: [{ type: "settingsPage", id: "mattermost-settings", displayName: "Mattermost", exportName: "SettingsPage" }] },
|
|
109
|
+
// What agents do in Mattermost as their own bot (src/actions.ts). Agent-only: the host
|
|
110
|
+
// checks the caller is an agent and scopes it to that agent's company.
|
|
111
|
+
apiRoutes: [
|
|
112
|
+
route("help", "GET", "/mm/help"),
|
|
113
|
+
route("channels", "GET", "/mm/channels"),
|
|
114
|
+
route("channel-posts", "GET", "/mm/channels/:channel/posts"),
|
|
115
|
+
route("thread", "GET", "/mm/posts/:postId/thread"),
|
|
116
|
+
route("channel-members", "GET", "/mm/channels/:channel/members"),
|
|
117
|
+
route("search", "GET", "/mm/search"),
|
|
118
|
+
route("people", "GET", "/mm/people"),
|
|
119
|
+
route("request", "GET", "/mm/requests/:id"),
|
|
120
|
+
route("post", "POST", "/mm/post"),
|
|
121
|
+
route("react", "POST", "/mm/react"),
|
|
122
|
+
route("dm", "POST", "/mm/dm"),
|
|
123
|
+
route("create-channel", "POST", "/mm/channels"),
|
|
124
|
+
route("invite", "POST", "/mm/channels/:channel/invite"),
|
|
125
|
+
route("update-channel", "POST", "/mm/channels/:channel/update"),
|
|
126
|
+
route("archive", "POST", "/mm/channels/:channel/archive")
|
|
127
|
+
],
|
|
128
|
+
// Installed in each company's skill library; the settings page adds it to every agent with a bot.
|
|
129
|
+
skills: [{ skillKey: "mattermost", displayName: "Mattermost", slug: "mattermost", description: "Talk to people and agents in Mattermost as your own bot.", markdown: SKILL }],
|
|
34
130
|
webhooks: [
|
|
35
131
|
{ endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
|
|
36
132
|
],
|
|
@@ -80,6 +176,14 @@ var manifest = {
|
|
|
80
176
|
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
177
|
items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
|
|
82
178
|
},
|
|
179
|
+
agentTurnLimit: {
|
|
180
|
+
type: "integer",
|
|
181
|
+
minimum: 1,
|
|
182
|
+
maximum: 100,
|
|
183
|
+
default: 10,
|
|
184
|
+
title: "Agent-to-agent turns",
|
|
185
|
+
description: "How many messages agents may trade in one thread, mentioning each other, before a person must reply."
|
|
186
|
+
},
|
|
83
187
|
approvers: {
|
|
84
188
|
type: "array",
|
|
85
189
|
title: "Approvers",
|
package/dist/ui/index.js
CHANGED
|
@@ -203,6 +203,8 @@ function SettingsPage({ context }) {
|
|
|
203
203
|
const [saved, setSaved] = useState(false);
|
|
204
204
|
const [check, setCheck] = useState(null);
|
|
205
205
|
const verify = usePluginAction("verify-url");
|
|
206
|
+
const skill = usePluginAction("skill");
|
|
207
|
+
const [skillNote, setSkillNote] = useState(null);
|
|
206
208
|
useEffect(() => {
|
|
207
209
|
if (!companyId) return;
|
|
208
210
|
Promise.all([
|
|
@@ -264,8 +266,22 @@ function SettingsPage({ context }) {
|
|
|
264
266
|
else next.add(id);
|
|
265
267
|
setSelected(next);
|
|
266
268
|
};
|
|
269
|
+
async function giveSkill(targets) {
|
|
270
|
+
try {
|
|
271
|
+
const { key } = await skill({ companyId });
|
|
272
|
+
if (!key) return "The Mattermost skill is not installed yet; save again in a minute.";
|
|
273
|
+
const failed = [];
|
|
274
|
+
for (const a of targets) {
|
|
275
|
+
await api(`/api/agents/${a.id}/skills/sync`, { method: "POST", body: JSON.stringify({ mode: "add", desiredSkills: [key] }) }).catch(() => failed.push(a.name));
|
|
276
|
+
}
|
|
277
|
+
return failed.length ? `Could not give the Mattermost skill to ${failed.join(", ")}; add it on their Skills tab.` : null;
|
|
278
|
+
} catch (e) {
|
|
279
|
+
return `Could not give agents the Mattermost skill: ${String(e?.message ?? e).slice(0, 200)}`;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
267
282
|
async function save() {
|
|
268
283
|
setSaveError(null);
|
|
284
|
+
setSkillNote(null);
|
|
269
285
|
const errors = validate(config);
|
|
270
286
|
if (agentsMode === "selected" && !pickedAgents.size) errors.agents = "Tick at least one agent, or choose All agents.";
|
|
271
287
|
if (access === "selected" && !grants.size) errors.access = "Tick at least one person, or choose Everyone.";
|
|
@@ -283,6 +299,7 @@ function SettingsPage({ context }) {
|
|
|
283
299
|
await api(`/api/plugins/${PLUGIN}/config`, { method: "POST", body: JSON.stringify({ companyId, configJson: next }) });
|
|
284
300
|
setConfig(next);
|
|
285
301
|
setSaved(true);
|
|
302
|
+
setSkillNote(await giveSkill(agentsMode === "selected" ? agents.filter((a) => pickedAgents.has(a.id)) : agents));
|
|
286
303
|
} catch (e) {
|
|
287
304
|
setSaveError(saveErrorText(String(e?.message ?? e)));
|
|
288
305
|
} finally {
|
|
@@ -323,7 +340,18 @@ function SettingsPage({ context }) {
|
|
|
323
340
|
/* @__PURE__ */ jsx("option", { value: "", children: "None: only in the agent's thread" }),
|
|
324
341
|
setup.data.channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
|
|
325
342
|
] }) : /* @__PURE__ */ jsx("input", { style: s.input, value: String(config.approvalsChannel ?? ""), onChange: (e) => set({ approvalsChannel: e.target.value }), placeholder: "channel name, e.g. approvals" }) }),
|
|
326
|
-
/* @__PURE__ */
|
|
343
|
+
/* @__PURE__ */ jsx(Field, { label: "Agent-to-agent turns", help: "Agents hand work to each other by mentioning one another in a thread. After this many agent messages in a row, they pause until a person replies.", children: /* @__PURE__ */ jsx(
|
|
344
|
+
"input",
|
|
345
|
+
{
|
|
346
|
+
style: { ...s.input, maxWidth: 120 },
|
|
347
|
+
type: "number",
|
|
348
|
+
min: 1,
|
|
349
|
+
max: 100,
|
|
350
|
+
value: String(config.agentTurnLimit ?? 10),
|
|
351
|
+
onChange: (e) => set({ agentTurnLimit: Math.min(100, Math.max(1, Math.round(Number(e.target.value) || 10))) })
|
|
352
|
+
}
|
|
353
|
+
) }),
|
|
354
|
+
/* @__PURE__ */ jsxs(Field, { label: "Agents in Mattermost", error: fieldErrors.agents, help: "Each agent shown here gets its own Mattermost bot, and saving gives it the Mattermost skill so it can message people from any run. Unticking an agent switches its bot off but keeps it, with its history, for when you tick it again.", children: [
|
|
327
355
|
/* @__PURE__ */ jsx(
|
|
328
356
|
Modes,
|
|
329
357
|
{
|
|
@@ -369,7 +397,8 @@ function SettingsPage({ context }) {
|
|
|
369
397
|
/* @__PURE__ */ jsxs("div", { style: s.row, children: [
|
|
370
398
|
/* @__PURE__ */ jsx("button", { style: s.primary, onClick: save, disabled: saving, children: saving ? "Saving\u2026" : "Save" }),
|
|
371
399
|
saved ? /* @__PURE__ */ jsx("span", { style: s.ok, children: "Saved" }) : null,
|
|
372
|
-
saveError ? /* @__PURE__ */ jsx("span", { style: s.bad, children: saveError }) : null
|
|
400
|
+
saveError ? /* @__PURE__ */ jsx("span", { style: s.bad, children: saveError }) : null,
|
|
401
|
+
skillNote ? /* @__PURE__ */ jsx("span", { style: s.bad, children: skillNote }) : null
|
|
373
402
|
] })
|
|
374
403
|
] });
|
|
375
404
|
}
|
package/dist/worker.js
CHANGED
|
@@ -844,14 +844,14 @@ function promiseAllObject(promisesObj) {
|
|
|
844
844
|
}
|
|
845
845
|
function randomString(length = 10) {
|
|
846
846
|
const chars = "abcdefghijklmnopqrstuvwxyz";
|
|
847
|
-
let
|
|
847
|
+
let str4 = "";
|
|
848
848
|
for (let i = 0; i < length; i++) {
|
|
849
|
-
|
|
849
|
+
str4 += chars[Math.floor(Math.random() * chars.length)];
|
|
850
850
|
}
|
|
851
|
-
return
|
|
851
|
+
return str4;
|
|
852
852
|
}
|
|
853
|
-
function esc(
|
|
854
|
-
return JSON.stringify(
|
|
853
|
+
function esc(str4) {
|
|
854
|
+
return JSON.stringify(str4);
|
|
855
855
|
}
|
|
856
856
|
function slugify(input2) {
|
|
857
857
|
return input2.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -965,8 +965,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
|
|
|
965
965
|
"symbol",
|
|
966
966
|
"undefined"
|
|
967
967
|
]);
|
|
968
|
-
function escapeRegex(
|
|
969
|
-
return
|
|
968
|
+
function escapeRegex(str4) {
|
|
969
|
+
return str4.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
970
970
|
}
|
|
971
971
|
function clone(inst, def2, params) {
|
|
972
972
|
const cl = new inst._zod.constr(def2 ?? inst._zod.def);
|
|
@@ -1232,13 +1232,13 @@ function getSizableOrigin(input2) {
|
|
|
1232
1232
|
return "unknown";
|
|
1233
1233
|
}
|
|
1234
1234
|
var highSurrogate = /[\uD800-\uDBFF]/;
|
|
1235
|
-
function codePointLength(
|
|
1236
|
-
const units =
|
|
1237
|
-
if (!highSurrogate.test(
|
|
1235
|
+
function codePointLength(str4) {
|
|
1236
|
+
const units = str4.length;
|
|
1237
|
+
if (!highSurrogate.test(str4))
|
|
1238
1238
|
return units;
|
|
1239
1239
|
let count = units;
|
|
1240
1240
|
for (let i = 0; i < units - 1; i++) {
|
|
1241
|
-
if ((
|
|
1241
|
+
if ((str4.charCodeAt(i) & 64512) === 55296 && (str4.charCodeAt(i + 1) & 64512) === 56320) {
|
|
1242
1242
|
count--;
|
|
1243
1243
|
i++;
|
|
1244
1244
|
}
|
|
@@ -16214,8 +16214,8 @@ var contributors = {
|
|
|
16214
16214
|
function aggregateChecks(schema) {
|
|
16215
16215
|
const agg = {};
|
|
16216
16216
|
const def2 = schema._zod.def;
|
|
16217
|
-
const
|
|
16218
|
-
for (const ch of
|
|
16217
|
+
const list2 = schema._zod.traits.has("$ZodCheck") ? [schema, ...def2.checks ?? []] : def2.checks ?? [];
|
|
16218
|
+
for (const ch of list2)
|
|
16219
16219
|
contributors[ch._zod.def.check]?.(agg, ch._zod.def);
|
|
16220
16220
|
const bag = schema._zod.bag;
|
|
16221
16221
|
if (bag.minimum !== void 0)
|
|
@@ -30482,28 +30482,28 @@ var aiProviderRoutingSchema = external_exports.object({
|
|
|
30482
30482
|
label: external_exports.string().trim().max(160).optional()
|
|
30483
30483
|
}).strict()
|
|
30484
30484
|
).max(200).default([])
|
|
30485
|
-
}).strict().superRefine((
|
|
30485
|
+
}).strict().superRefine((route2, ctx2) => {
|
|
30486
30486
|
const invalid = (message2) => ctx2.addIssue({ code: "custom", message: message2 });
|
|
30487
|
-
if (
|
|
30488
|
-
if (
|
|
30487
|
+
if (route2.kind === "bedrock") {
|
|
30488
|
+
if (route2.protocol !== "bedrock" || !route2.region || route2.auth !== "bearer" || route2.baseUrl)
|
|
30489
30489
|
invalid(
|
|
30490
30490
|
"Bedrock requires a region and a Bedrock API key."
|
|
30491
30491
|
);
|
|
30492
|
-
} else if (
|
|
30492
|
+
} else if (route2.protocol === "bedrock" || route2.region)
|
|
30493
30493
|
invalid("AWS settings require Bedrock.");
|
|
30494
|
-
if (
|
|
30494
|
+
if (route2.kind === "openrouter" && (route2.baseUrl || route2.auth !== "bearer"))
|
|
30495
30495
|
invalid("OpenRouter uses its official endpoint and an API key.");
|
|
30496
|
-
if (
|
|
30496
|
+
if (route2.auth === "api_key" && route2.protocol !== "messages")
|
|
30497
30497
|
invalid("API-key header authentication requires Anthropic Messages.");
|
|
30498
|
-
if (
|
|
30498
|
+
if (route2.kind === "gateway" || route2.kind === "local") {
|
|
30499
30499
|
try {
|
|
30500
|
-
const url2 = new URL(
|
|
30500
|
+
const url2 = new URL(route2.baseUrl ?? "");
|
|
30501
30501
|
const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(
|
|
30502
30502
|
url2.hostname
|
|
30503
30503
|
);
|
|
30504
30504
|
if (url2.username || url2.password || url2.search || url2.hash || url2.protocol !== "https:" && !(url2.protocol === "http:" && loopback))
|
|
30505
30505
|
throw new Error();
|
|
30506
|
-
if (
|
|
30506
|
+
if (route2.kind === "local" && !loopback)
|
|
30507
30507
|
invalid("A local endpoint must use localhost or a loopback address.");
|
|
30508
30508
|
} catch {
|
|
30509
30509
|
invalid(
|
|
@@ -33606,7 +33606,7 @@ var pluginManifestV1Schema = external_exports.object({
|
|
|
33606
33606
|
}
|
|
33607
33607
|
}
|
|
33608
33608
|
if (manifest2.apiRoutes) {
|
|
33609
|
-
const routeKeys = manifest2.apiRoutes.map((
|
|
33609
|
+
const routeKeys = manifest2.apiRoutes.map((route2) => route2.routeKey);
|
|
33610
33610
|
const duplicateKeys = routeKeys.filter((key, i) => routeKeys.indexOf(key) !== i);
|
|
33611
33611
|
if (duplicateKeys.length > 0) {
|
|
33612
33612
|
ctx2.addIssue({
|
|
@@ -33615,7 +33615,7 @@ var pluginManifestV1Schema = external_exports.object({
|
|
|
33615
33615
|
path: ["apiRoutes"]
|
|
33616
33616
|
});
|
|
33617
33617
|
}
|
|
33618
|
-
const routeSignatures = manifest2.apiRoutes.map((
|
|
33618
|
+
const routeSignatures = manifest2.apiRoutes.map((route2) => `${route2.method} ${route2.path}`);
|
|
33619
33619
|
const duplicateRoutes = routeSignatures.filter((sig2, i) => routeSignatures.indexOf(sig2) !== i);
|
|
33620
33620
|
if (duplicateRoutes.length > 0) {
|
|
33621
33621
|
ctx2.addIssue({
|
|
@@ -42172,17 +42172,17 @@ function aggregatorAppIdentity(value) {
|
|
|
42172
42172
|
return SERVICE_IDENTITY_ALIASES.get(identity) ?? identity;
|
|
42173
42173
|
}
|
|
42174
42174
|
var entries = /* @__PURE__ */ new Map();
|
|
42175
|
-
function add(name, slug, aliases,
|
|
42175
|
+
function add(name, slug, aliases, route2) {
|
|
42176
42176
|
const identity = aggregatorAppIdentity(name);
|
|
42177
42177
|
const existing = entries.get(identity);
|
|
42178
42178
|
if (existing) {
|
|
42179
42179
|
existing.aliases = [.../* @__PURE__ */ new Set([...existing.aliases, name, slug, ...aliases])];
|
|
42180
|
-
if (!existing.routes.some((candidate) => candidate.provider ===
|
|
42181
|
-
existing.routes.push(
|
|
42180
|
+
if (!existing.routes.some((candidate) => candidate.provider === route2.provider)) {
|
|
42181
|
+
existing.routes.push(route2);
|
|
42182
42182
|
}
|
|
42183
42183
|
return;
|
|
42184
42184
|
}
|
|
42185
|
-
entries.set(identity, { slug, name: name.replace(/\s+(MCP|API)$/i, ""), aliases, routes: [
|
|
42185
|
+
entries.set(identity, { slug, name: name.replace(/\s+(MCP|API)$/i, ""), aliases, routes: [route2] });
|
|
42186
42186
|
}
|
|
42187
42187
|
for (const [toolkit, name] of composio_search_catalog_default.toolkits) {
|
|
42188
42188
|
add(name, toolkit.replaceAll("_", "-").replace(/^-+/, ""), [toolkit], {
|
|
@@ -44992,7 +44992,512 @@ function chosenProps(choice, who) {
|
|
|
44992
44992
|
return { attachments: [{ text: `${who} chose **${choice}**`, actions: [] }] };
|
|
44993
44993
|
}
|
|
44994
44994
|
|
|
44995
|
+
// src/policy.ts
|
|
44996
|
+
var ALLOW = { ok: true };
|
|
44997
|
+
var ask = (why) => ({ ok: false, askFirst: true, why });
|
|
44998
|
+
var refuse = (why) => ({ ok: false, askFirst: false, why });
|
|
44999
|
+
function broadcastMention(text2) {
|
|
45000
|
+
const m = text2.match(/(?:^|[^\w@])@(channel|all|here)\b/i);
|
|
45001
|
+
return m ? `@${m[1].toLowerCase()}` : null;
|
|
45002
|
+
}
|
|
45003
|
+
function canPost(text2) {
|
|
45004
|
+
const b = broadcastMention(text2);
|
|
45005
|
+
return b ? ask(`${b} notifies everyone in the channel`) : ALLOW;
|
|
45006
|
+
}
|
|
45007
|
+
function canCreateChannel(isPrivate) {
|
|
45008
|
+
return isPrivate ? ALLOW : ask("a public channel is visible to the whole team");
|
|
45009
|
+
}
|
|
45010
|
+
function canManage(createdByThisAgent, action) {
|
|
45011
|
+
if (createdByThisAgent) return ALLOW;
|
|
45012
|
+
if (action === "invite") return ask("the agent did not create this channel");
|
|
45013
|
+
return refuse("only the agent that created a channel may change or archive it");
|
|
45014
|
+
}
|
|
45015
|
+
function canDm(allowedToTalk) {
|
|
45016
|
+
return allowedToTalk ? ALLOW : refuse("that person is not allowed to talk to this agent");
|
|
45017
|
+
}
|
|
45018
|
+
function channelSlug(name) {
|
|
45019
|
+
return name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "work";
|
|
45020
|
+
}
|
|
45021
|
+
|
|
45022
|
+
// src/actions.ts
|
|
45023
|
+
var ok = (body) => ({ status: 200, body });
|
|
45024
|
+
var bad = (status, error62) => ({ status, body: { error: error62 } });
|
|
45025
|
+
var str3 = (v) => typeof v === "string" ? v.trim() : "";
|
|
45026
|
+
var list = (v) => Array.isArray(v) ? v.map(str3).filter(Boolean) : typeof v === "string" && v.trim() ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
|
45027
|
+
var ID = /^[a-z0-9]{26}$/;
|
|
45028
|
+
var Actions = class {
|
|
45029
|
+
d;
|
|
45030
|
+
names = /* @__PURE__ */ new Map();
|
|
45031
|
+
constructor(deps) {
|
|
45032
|
+
this.d = deps;
|
|
45033
|
+
}
|
|
45034
|
+
/** One route call. `params` are the route's path parameters, `input` its query or body. */
|
|
45035
|
+
async handle(agentId, routeKey, params, input2) {
|
|
45036
|
+
const bot = this.d.bot(agentId);
|
|
45037
|
+
if (!bot) return bad(409, "You have no Mattermost bot in this company yet (your agent may be switched off in the plugin's settings).");
|
|
45038
|
+
try {
|
|
45039
|
+
switch (routeKey) {
|
|
45040
|
+
case "channels":
|
|
45041
|
+
return ok(await this.myChannels(bot));
|
|
45042
|
+
case "channel-posts":
|
|
45043
|
+
return ok(await this.posts(bot, params.channel, input2));
|
|
45044
|
+
case "thread":
|
|
45045
|
+
return ok(await this.thread(bot, params.postId));
|
|
45046
|
+
case "channel-members":
|
|
45047
|
+
return ok(await this.members(bot, params.channel));
|
|
45048
|
+
case "search":
|
|
45049
|
+
return ok(await this.search(bot, str3(input2.q)));
|
|
45050
|
+
case "people":
|
|
45051
|
+
return ok(await this.people(str3(input2.q)));
|
|
45052
|
+
case "post":
|
|
45053
|
+
return await this.post(agentId, bot, input2);
|
|
45054
|
+
case "react":
|
|
45055
|
+
return await this.react(bot, input2);
|
|
45056
|
+
case "dm":
|
|
45057
|
+
return await this.dm(agentId, bot, input2);
|
|
45058
|
+
case "create-channel":
|
|
45059
|
+
return await this.createChannel(agentId, bot, input2);
|
|
45060
|
+
case "invite":
|
|
45061
|
+
return await this.invite(agentId, bot, params.channel, input2);
|
|
45062
|
+
case "update-channel":
|
|
45063
|
+
return await this.updateChannel(agentId, bot, params.channel, input2);
|
|
45064
|
+
case "archive":
|
|
45065
|
+
return await this.archive(agentId, bot, params.channel);
|
|
45066
|
+
case "request":
|
|
45067
|
+
return ok(await this.d.store.get(`req:${params.id}`) ?? { error: "no such request" });
|
|
45068
|
+
default:
|
|
45069
|
+
return bad(404, `unknown action ${routeKey}`);
|
|
45070
|
+
}
|
|
45071
|
+
} catch (err) {
|
|
45072
|
+
const status = err?.status;
|
|
45073
|
+
const message2 = String(err?.message ?? err).replace(/^Mattermost \w+ \S+: \d+ /, "");
|
|
45074
|
+
return bad(status && status >= 400 && status < 500 ? status : 502, `Mattermost refused or failed: ${message2.slice(0, 300)}`);
|
|
45075
|
+
}
|
|
45076
|
+
}
|
|
45077
|
+
// ---- reading -------------------------------------------------------------------
|
|
45078
|
+
async teamOf(bot) {
|
|
45079
|
+
const teams = await bot.request("GET", "/users/me/teams");
|
|
45080
|
+
if (!teams.length) throw Object.assign(new Error("your bot is in no team"), { status: 409 });
|
|
45081
|
+
return teams[0].id;
|
|
45082
|
+
}
|
|
45083
|
+
/** A channel by id, URL name ("decisions"), display name ("Decisions") or "~decisions". */
|
|
45084
|
+
async channel(bot, ref) {
|
|
45085
|
+
const want = str3(ref).replace(/^[~#]/, "");
|
|
45086
|
+
if (ID.test(want)) return bot.request("GET", `/channels/${want}`);
|
|
45087
|
+
for (const team of await bot.request("GET", "/users/me/teams")) {
|
|
45088
|
+
try {
|
|
45089
|
+
return await bot.request("GET", `/teams/${team.id}/channels/name/${encodeURIComponent(channelSlug(want))}`);
|
|
45090
|
+
} catch {
|
|
45091
|
+
}
|
|
45092
|
+
const mine = await bot.request("GET", `/users/me/teams/${team.id}/channels`).catch(() => []);
|
|
45093
|
+
const hit = mine.find((c) => c.display_name.toLowerCase() === want.toLowerCase());
|
|
45094
|
+
if (hit) return hit;
|
|
45095
|
+
}
|
|
45096
|
+
throw Object.assign(new Error(`no channel "${ref}" that you can see; you can only use channels you are a member of`), { status: 404 });
|
|
45097
|
+
}
|
|
45098
|
+
async userName(id) {
|
|
45099
|
+
if (!this.names.has(id)) {
|
|
45100
|
+
const u = await this.d.manager.request("GET", `/users/${id}`).catch(() => ({ username: id }));
|
|
45101
|
+
this.names.set(id, u.username);
|
|
45102
|
+
}
|
|
45103
|
+
return this.names.get(id);
|
|
45104
|
+
}
|
|
45105
|
+
async shape(p) {
|
|
45106
|
+
return { id: p.id, from: `@${await this.userName(p.user_id)}`, at: new Date(p.create_at).toISOString(), threadId: p.root_id || null, text: p.message };
|
|
45107
|
+
}
|
|
45108
|
+
async ordered(res) {
|
|
45109
|
+
const posts = res.order.map((id) => res.posts[id]).filter(Boolean).sort((a, b) => a.create_at - b.create_at);
|
|
45110
|
+
return Promise.all(posts.map((p) => this.shape(p)));
|
|
45111
|
+
}
|
|
45112
|
+
async myChannels(bot) {
|
|
45113
|
+
const team = await this.teamOf(bot);
|
|
45114
|
+
const created = await this.created();
|
|
45115
|
+
const channels = await bot.request("GET", `/users/me/teams/${team}/channels`);
|
|
45116
|
+
return channels.filter((c) => c.type === "O" || c.type === "P").map((c) => ({
|
|
45117
|
+
id: c.id,
|
|
45118
|
+
name: c.name,
|
|
45119
|
+
displayName: c.display_name,
|
|
45120
|
+
private: c.type === "P",
|
|
45121
|
+
purpose: c.purpose || "",
|
|
45122
|
+
createdByYou: created[c.id] === bot.botUserId
|
|
45123
|
+
}));
|
|
45124
|
+
}
|
|
45125
|
+
async posts(bot, ref, input2) {
|
|
45126
|
+
const ch = await this.channel(bot, ref);
|
|
45127
|
+
const limit = Math.min(Math.max(Number(input2.limit) || 30, 1), 100);
|
|
45128
|
+
return { channel: ch.name, posts: await this.ordered(await bot.request(`GET`, `/channels/${ch.id}/posts?per_page=${limit}`)) };
|
|
45129
|
+
}
|
|
45130
|
+
async thread(bot, postId) {
|
|
45131
|
+
return { posts: await this.ordered(await bot.request("GET", `/posts/${postId}/thread`)) };
|
|
45132
|
+
}
|
|
45133
|
+
async members(bot, ref) {
|
|
45134
|
+
const ch = await this.channel(bot, ref);
|
|
45135
|
+
const ms = await bot.request("GET", `/channels/${ch.id}/members?per_page=200`);
|
|
45136
|
+
const agents = new Map(this.d.agents().map((a) => [a.botUserId, a]));
|
|
45137
|
+
return Promise.all(ms.map(async (m) => {
|
|
45138
|
+
const a = agents.get(m.user_id);
|
|
45139
|
+
return a ? { username: a.username, agent: a.agentName } : { username: await this.userName(m.user_id) };
|
|
45140
|
+
}));
|
|
45141
|
+
}
|
|
45142
|
+
async search(bot, q) {
|
|
45143
|
+
if (!q) throw Object.assign(new Error("say what to search for (q)"), { status: 400 });
|
|
45144
|
+
const team = await this.teamOf(bot);
|
|
45145
|
+
return { posts: (await this.ordered(await bot.request("POST", `/teams/${team}/posts/search`, { terms: q, is_or_search: false }))).slice(-30) };
|
|
45146
|
+
}
|
|
45147
|
+
async people(q) {
|
|
45148
|
+
const users = await this.d.manager.request(
|
|
45149
|
+
"POST",
|
|
45150
|
+
"/users/search",
|
|
45151
|
+
{ term: q, allow_inactive: false, limit: 25 }
|
|
45152
|
+
);
|
|
45153
|
+
const agents = new Map(this.d.agents().map((a) => [a.botUserId, a]));
|
|
45154
|
+
return users.filter((u) => !u.delete_at && (!u.is_bot || agents.has(u.id))).map((u) => agents.has(u.id) ? { username: u.username, agent: agents.get(u.id).agentName } : { username: u.username, name: [u.first_name, u.last_name].filter(Boolean).join(" ") || u.username });
|
|
45155
|
+
}
|
|
45156
|
+
// ---- talking ---------------------------------------------------------------------
|
|
45157
|
+
async gate(agentId, verdict, kind, summary, args) {
|
|
45158
|
+
if (verdict.ok) return null;
|
|
45159
|
+
if (!verdict.askFirst) return bad(403, `Not allowed: ${verdict.why}.`);
|
|
45160
|
+
const request = {
|
|
45161
|
+
id: this.d.newId(),
|
|
45162
|
+
agentId,
|
|
45163
|
+
agentName: this.agentName(agentId),
|
|
45164
|
+
kind,
|
|
45165
|
+
summary,
|
|
45166
|
+
why: verdict.why,
|
|
45167
|
+
args,
|
|
45168
|
+
origin: this.d.origin(agentId),
|
|
45169
|
+
status: "pending"
|
|
45170
|
+
};
|
|
45171
|
+
await this.d.store.set(`req:${request.id}`, request);
|
|
45172
|
+
await this.d.askApproval(request);
|
|
45173
|
+
return { status: 202, body: {
|
|
45174
|
+
status: "waiting_for_approval",
|
|
45175
|
+
requestId: request.id,
|
|
45176
|
+
why: verdict.why,
|
|
45177
|
+
note: "A person has been asked to approve this. Tell whoever you are working with, and carry on; if approved it runs as you, and the result is posted where you asked."
|
|
45178
|
+
} };
|
|
45179
|
+
}
|
|
45180
|
+
agentName(agentId) {
|
|
45181
|
+
return this.d.agents().find((a) => a.agentId === agentId)?.agentName ?? "An agent";
|
|
45182
|
+
}
|
|
45183
|
+
async post(agentId, bot, input2) {
|
|
45184
|
+
const text2 = str3(input2.text);
|
|
45185
|
+
if (!text2) return bad(400, "text is empty");
|
|
45186
|
+
const ch = await this.channel(bot, str3(input2.channel));
|
|
45187
|
+
const threadId = str3(input2.threadId);
|
|
45188
|
+
const waiting = await this.gate(agentId, canPost(text2), "post", `post in ~${ch.name}: "${text2.slice(0, 200)}"`, { channelId: ch.id, threadId, text: text2 });
|
|
45189
|
+
if (waiting) return waiting;
|
|
45190
|
+
return ok(await this.doPost(bot, ch.id, threadId, text2));
|
|
45191
|
+
}
|
|
45192
|
+
async doPost(bot, channelId, threadId, text2) {
|
|
45193
|
+
const p = await bot.request("POST", "/posts", { channel_id: channelId, root_id: threadId, message: text2 });
|
|
45194
|
+
return { posted: p.id, threadId: p.root_id || p.id };
|
|
45195
|
+
}
|
|
45196
|
+
async react(bot, input2) {
|
|
45197
|
+
const emoji3 = str3(input2.emoji).replace(/^:|:$/g, "");
|
|
45198
|
+
if (!str3(input2.postId) || !emoji3) return bad(400, "postId and emoji are required");
|
|
45199
|
+
await bot.request("POST", "/reactions", { user_id: bot.botUserId, post_id: str3(input2.postId), emoji_name: emoji3 });
|
|
45200
|
+
return ok({ reacted: emoji3 });
|
|
45201
|
+
}
|
|
45202
|
+
async dm(agentId, bot, input2) {
|
|
45203
|
+
const text2 = str3(input2.text);
|
|
45204
|
+
const username = str3(input2.person).replace(/^@/, "");
|
|
45205
|
+
if (!text2 || !username) return bad(400, "person and text are required");
|
|
45206
|
+
const user2 = await this.d.manager.request("GET", `/users/username/${encodeURIComponent(username)}`);
|
|
45207
|
+
const verdict = canDm(await this.d.canTalk(user2.id, agentId));
|
|
45208
|
+
if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
|
|
45209
|
+
const channel2 = await bot.request("POST", "/channels/direct", [bot.botUserId, user2.id]);
|
|
45210
|
+
return ok(await this.doPost(bot, channel2.id, "", text2));
|
|
45211
|
+
}
|
|
45212
|
+
// ---- channels ----------------------------------------------------------------------
|
|
45213
|
+
async created() {
|
|
45214
|
+
return await this.d.store.get("channels-created") ?? {};
|
|
45215
|
+
}
|
|
45216
|
+
async createdBy(channelId) {
|
|
45217
|
+
return (await this.created())[channelId];
|
|
45218
|
+
}
|
|
45219
|
+
async resolvePeople(names) {
|
|
45220
|
+
const agents = this.d.agents();
|
|
45221
|
+
const ids = [];
|
|
45222
|
+
const unknown2 = [];
|
|
45223
|
+
for (const raw of names) {
|
|
45224
|
+
const n = raw.replace(/^@/, "").toLowerCase();
|
|
45225
|
+
const a = agents.find((x) => x.username === n || x.agentName.toLowerCase() === n);
|
|
45226
|
+
if (a) {
|
|
45227
|
+
ids.push(a.botUserId);
|
|
45228
|
+
continue;
|
|
45229
|
+
}
|
|
45230
|
+
const u = await this.d.manager.request("GET", `/users/username/${encodeURIComponent(n)}`).catch(() => null);
|
|
45231
|
+
if (u) ids.push(u.id);
|
|
45232
|
+
else unknown2.push(raw);
|
|
45233
|
+
}
|
|
45234
|
+
return { ids, unknown: unknown2 };
|
|
45235
|
+
}
|
|
45236
|
+
async addMembers(channelId, ids) {
|
|
45237
|
+
for (const id of ids) await this.d.manager.request("POST", `/channels/${channelId}/members`, { user_id: id }).catch(() => {
|
|
45238
|
+
});
|
|
45239
|
+
}
|
|
45240
|
+
async createChannel(agentId, bot, input2) {
|
|
45241
|
+
const isPrivate = input2.private !== false;
|
|
45242
|
+
const display = str3(input2.name);
|
|
45243
|
+
if (!display) return bad(400, "name is required, e.g. 'inc checkout 5xx'");
|
|
45244
|
+
const taskId = str3(input2.taskId);
|
|
45245
|
+
if (taskId && !await this.d.task(taskId)) return bad(404, `no task ${taskId} in this company`);
|
|
45246
|
+
const args = { name: display, private: isPrivate, purpose: str3(input2.purpose), people: list(input2.people), agents: list(input2.agents), taskId };
|
|
45247
|
+
const waiting = await this.gate(agentId, canCreateChannel(isPrivate), "create-channel", `open the public channel "${display}"`, args);
|
|
45248
|
+
if (waiting) return waiting;
|
|
45249
|
+
return ok(await this.doCreateChannel(agentId, bot, args, this.d.origin(agentId)));
|
|
45250
|
+
}
|
|
45251
|
+
async doCreateChannel(agentId, bot, args, origin) {
|
|
45252
|
+
const team = await this.teamOf(bot);
|
|
45253
|
+
const display = str3(args.name);
|
|
45254
|
+
let ch = null;
|
|
45255
|
+
for (const suffix of ["", "-2", "-3", "-4", "-5"]) {
|
|
45256
|
+
try {
|
|
45257
|
+
ch = await bot.request("POST", "/channels", {
|
|
45258
|
+
team_id: team,
|
|
45259
|
+
name: channelSlug(display) + suffix,
|
|
45260
|
+
display_name: display.slice(0, 64),
|
|
45261
|
+
type: args.private === false ? "O" : "P",
|
|
45262
|
+
purpose: str3(args.purpose).slice(0, 250)
|
|
45263
|
+
});
|
|
45264
|
+
break;
|
|
45265
|
+
} catch (err) {
|
|
45266
|
+
if (err.status !== 400) throw err;
|
|
45267
|
+
}
|
|
45268
|
+
}
|
|
45269
|
+
if (!ch) throw Object.assign(new Error(`a channel named like "${display}" exists already; pick another name`), { status: 409 });
|
|
45270
|
+
const created = await this.created();
|
|
45271
|
+
created[ch.id] = bot.botUserId;
|
|
45272
|
+
await this.d.store.set("channels-created", created);
|
|
45273
|
+
const { ids, unknown: unknown2 } = await this.resolvePeople([...list(args.people), ...list(args.agents)]);
|
|
45274
|
+
await this.addMembers(ch.id, ids);
|
|
45275
|
+
let task2 = null;
|
|
45276
|
+
const taskId = str3(args.taskId);
|
|
45277
|
+
if (taskId) {
|
|
45278
|
+
const t = await this.d.task(taskId);
|
|
45279
|
+
if (t) {
|
|
45280
|
+
task2 = { issueId: t.id, identifier: t.identifier ?? null, title: t.title, agentId, origin };
|
|
45281
|
+
await this.d.store.set(`worklink:${ch.id}`, task2);
|
|
45282
|
+
await this.d.store.set(`taskchannel:${t.id}`, ch.id);
|
|
45283
|
+
}
|
|
45284
|
+
}
|
|
45285
|
+
return { channel: ch.name, channelId: ch.id, private: ch.type === "P", added: ids.length, unknownPeople: unknown2, linkedTask: task2?.identifier ?? task2?.issueId ?? null };
|
|
45286
|
+
}
|
|
45287
|
+
async invite(agentId, bot, ref, input2) {
|
|
45288
|
+
const ch = await this.channel(bot, ref);
|
|
45289
|
+
const names = [...list(input2.people), ...list(input2.agents)];
|
|
45290
|
+
if (!names.length) return bad(400, "people or agents to invite are required");
|
|
45291
|
+
const mine = await this.createdBy(ch.id) === bot.botUserId;
|
|
45292
|
+
const waiting = await this.gate(agentId, canManage(mine, "invite"), "invite", `add ${names.join(", ")} to ~${ch.name}`, { channelId: ch.id, names });
|
|
45293
|
+
if (waiting) return waiting;
|
|
45294
|
+
return ok(await this.doInvite(ch.id, names));
|
|
45295
|
+
}
|
|
45296
|
+
async doInvite(channelId, names) {
|
|
45297
|
+
const { ids, unknown: unknown2 } = await this.resolvePeople(names);
|
|
45298
|
+
await this.addMembers(channelId, ids);
|
|
45299
|
+
return { added: ids.length, unknownPeople: unknown2 };
|
|
45300
|
+
}
|
|
45301
|
+
async updateChannel(agentId, bot, ref, input2) {
|
|
45302
|
+
const ch = await this.channel(bot, ref);
|
|
45303
|
+
const verdict = canManage(await this.createdBy(ch.id) === bot.botUserId, "update");
|
|
45304
|
+
if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
|
|
45305
|
+
const patch = {};
|
|
45306
|
+
if (typeof input2.purpose === "string") patch.purpose = input2.purpose.slice(0, 250);
|
|
45307
|
+
if (typeof input2.header === "string") patch.header = input2.header.slice(0, 1024);
|
|
45308
|
+
if (typeof input2.name === "string" && input2.name.trim()) patch.display_name = input2.name.trim().slice(0, 64);
|
|
45309
|
+
if (!Object.keys(patch).length) return bad(400, "nothing to change: purpose, header or name");
|
|
45310
|
+
await this.d.manager.request("PUT", `/channels/${ch.id}/patch`, patch);
|
|
45311
|
+
return ok({ updated: Object.keys(patch) });
|
|
45312
|
+
}
|
|
45313
|
+
async archive(agentId, bot, ref) {
|
|
45314
|
+
const ch = await this.channel(bot, ref);
|
|
45315
|
+
const verdict = canManage(await this.createdBy(ch.id) === bot.botUserId, "archive");
|
|
45316
|
+
if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
|
|
45317
|
+
await this.d.manager.request("DELETE", `/channels/${ch.id}`);
|
|
45318
|
+
return ok({ archived: ch.name });
|
|
45319
|
+
}
|
|
45320
|
+
/** A person approved or rejected a request: run it as the agent, then report where it was asked. */
|
|
45321
|
+
async decide(requestId, approved, who) {
|
|
45322
|
+
const req = await this.d.store.get(`req:${requestId}`);
|
|
45323
|
+
if (!req || req.status !== "pending") return req;
|
|
45324
|
+
req.decidedBy = who;
|
|
45325
|
+
if (!approved) {
|
|
45326
|
+
req.status = "rejected";
|
|
45327
|
+
} else {
|
|
45328
|
+
const bot2 = this.d.bot(req.agentId);
|
|
45329
|
+
try {
|
|
45330
|
+
if (!bot2) throw new Error("the agent has no bot any more");
|
|
45331
|
+
const a = req.args;
|
|
45332
|
+
req.result = req.kind === "post" ? await this.doPost(bot2, str3(a.channelId), str3(a.threadId), str3(a.text)) : req.kind === "create-channel" ? await this.doCreateChannel(req.agentId, bot2, a, req.origin) : await this.doInvite(str3(a.channelId), list(a.names));
|
|
45333
|
+
req.status = "approved";
|
|
45334
|
+
} catch (err) {
|
|
45335
|
+
req.status = "failed";
|
|
45336
|
+
req.result = { error: String(err?.message ?? err).slice(0, 300) };
|
|
45337
|
+
}
|
|
45338
|
+
}
|
|
45339
|
+
await this.d.store.set(`req:${requestId}`, req);
|
|
45340
|
+
const bot = this.d.bot(req.agentId);
|
|
45341
|
+
if (bot && req.origin) {
|
|
45342
|
+
const line = req.status === "approved" ? `:white_check_mark: ${who} approved: ${req.summary}.` : req.status === "rejected" ? `:x: ${who} rejected: ${req.summary}.` : `:warning: Approved by ${who}, but it failed: ${req.result?.error}`;
|
|
45343
|
+
await bot.request("POST", "/posts", { channel_id: req.origin.channelId, root_id: req.origin.rootId, message: line }).catch(() => {
|
|
45344
|
+
});
|
|
45345
|
+
}
|
|
45346
|
+
return req;
|
|
45347
|
+
}
|
|
45348
|
+
};
|
|
45349
|
+
|
|
45350
|
+
// src/worklinks.ts
|
|
45351
|
+
var FROM_PAPERCLIP = { from_paperclip: "true" };
|
|
45352
|
+
var CLOSED = /* @__PURE__ */ new Set(["done", "cancelled"]);
|
|
45353
|
+
var STATUS_WORDS = {
|
|
45354
|
+
todo: "to do",
|
|
45355
|
+
in_progress: "in progress",
|
|
45356
|
+
in_review: "in review",
|
|
45357
|
+
blocked: "blocked",
|
|
45358
|
+
backlog: "in the backlog",
|
|
45359
|
+
done: "done",
|
|
45360
|
+
cancelled: "cancelled"
|
|
45361
|
+
};
|
|
45362
|
+
var WorkLinks = class {
|
|
45363
|
+
d;
|
|
45364
|
+
constructor(deps) {
|
|
45365
|
+
this.d = deps;
|
|
45366
|
+
}
|
|
45367
|
+
async linked(issueId) {
|
|
45368
|
+
const channelId = await this.d.store.get(`taskchannel:${issueId}`);
|
|
45369
|
+
const link = channelId ? await this.d.store.get(`worklink:${channelId}`) : null;
|
|
45370
|
+
return channelId && link && !link.closed ? { channelId, link } : null;
|
|
45371
|
+
}
|
|
45372
|
+
label(link) {
|
|
45373
|
+
return link.identifier ?? link.title;
|
|
45374
|
+
}
|
|
45375
|
+
/** A new comment on a task with a work channel. */
|
|
45376
|
+
async comment(issueId, commentId) {
|
|
45377
|
+
const hit = await this.linked(issueId);
|
|
45378
|
+
if (!hit) return;
|
|
45379
|
+
const c = (await this.d.comments(issueId)).find((x) => x.id === commentId);
|
|
45380
|
+
if (!c?.body?.trim()) return;
|
|
45381
|
+
const body = c.body.trim().slice(0, 12e3);
|
|
45382
|
+
const own2 = c.authorAgentId && this.d.hasBot(c.authorAgentId);
|
|
45383
|
+
const text2 = own2 ? body : `**${c.authorAgentId ? this.d.agentName(c.authorAgentId) : "A team member"}** commented on ${this.label(hit.link)}:
|
|
45384
|
+
|
|
45385
|
+
${body}`;
|
|
45386
|
+
await this.d.post(own2 ? c.authorAgentId : hit.link.agentId, hit.channelId, "", text2);
|
|
45387
|
+
}
|
|
45388
|
+
/** The task changed: note a new status; on done or cancelled, archive and report back. */
|
|
45389
|
+
async updated(issueId) {
|
|
45390
|
+
const hit = await this.linked(issueId);
|
|
45391
|
+
if (!hit) return;
|
|
45392
|
+
const task2 = await this.d.task(issueId);
|
|
45393
|
+
if (!task2 || task2.status === hit.link.status) return;
|
|
45394
|
+
const { channelId, link } = hit;
|
|
45395
|
+
const known = link.status !== void 0;
|
|
45396
|
+
link.status = task2.status;
|
|
45397
|
+
if (!CLOSED.has(task2.status)) {
|
|
45398
|
+
await this.d.store.set(`worklink:${channelId}`, link);
|
|
45399
|
+
if (known) await this.d.post(link.agentId, channelId, "", `_${this.label(link)} is now ${STATUS_WORDS[task2.status] ?? task2.status}._`);
|
|
45400
|
+
return;
|
|
45401
|
+
}
|
|
45402
|
+
link.closed = true;
|
|
45403
|
+
await this.d.store.set(`worklink:${channelId}`, link);
|
|
45404
|
+
const last = (await this.d.comments(issueId)).at(-1)?.body?.trim();
|
|
45405
|
+
const outcome = last ? `
|
|
45406
|
+
|
|
45407
|
+
${last.length > 1500 ? last.slice(0, 1500) + "\u2026" : last}` : "";
|
|
45408
|
+
const done = task2.status === "done";
|
|
45409
|
+
const name = await this.d.channelName(channelId).catch(() => "the work channel");
|
|
45410
|
+
await this.d.post(link.agentId, channelId, "", `${done ? ":white_check_mark:" : ":no_entry_sign:"} ${this.label(link)} is ${task2.status}. Archiving this channel; it stays readable under archived channels.`);
|
|
45411
|
+
await this.d.archive(channelId);
|
|
45412
|
+
if (link.origin) {
|
|
45413
|
+
await this.d.post(
|
|
45414
|
+
link.agentId,
|
|
45415
|
+
link.origin.channelId,
|
|
45416
|
+
link.origin.rootId,
|
|
45417
|
+
`${done ? ":white_check_mark: Done" : ":no_entry_sign: Cancelled"}: **${this.label(link)}** ${task2.title}${outcome}
|
|
45418
|
+
|
|
45419
|
+
_~${name} is archived._`
|
|
45420
|
+
);
|
|
45421
|
+
}
|
|
45422
|
+
}
|
|
45423
|
+
};
|
|
45424
|
+
|
|
45425
|
+
// src/help.ts
|
|
45426
|
+
var HELP = `# Working in Mattermost
|
|
45427
|
+
|
|
45428
|
+
You have your own Mattermost account (a bot). Everything below runs as you, in your company's
|
|
45429
|
+
Mattermost, and you can only see and use channels you are a member of. Use it whenever you need
|
|
45430
|
+
to reach a person or another agent in Mattermost, including from a task or a scheduled run: to
|
|
45431
|
+
ping someone, DM them, or post in a channel. Call it with your Paperclip API credentials:
|
|
45432
|
+
|
|
45433
|
+
B="\${PAPERCLIP_API_URL%/}"; B="\${B%/api}/api/plugins/mattermost-bridge/api"
|
|
45434
|
+
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" "$B/mm/channels"
|
|
45435
|
+
curl -s -H "Authorization: Bearer $PAPERCLIP_API_KEY" -H "content-type: application/json" \\
|
|
45436
|
+
-d '{"person":"username","text":"..."}' "$B/mm/dm"
|
|
45437
|
+
|
|
45438
|
+
Answers are JSON. A 202 with "waiting_for_approval" means a person has been asked to approve;
|
|
45439
|
+
tell whoever you are working with and carry on. A 403 says why something is not allowed.
|
|
45440
|
+
|
|
45441
|
+
## How to work
|
|
45442
|
+
|
|
45443
|
+
- **Pick the size of the work.** A quick question: answer in the thread. A task: create a Paperclip
|
|
45444
|
+
task (assign it to yourself or delegate it) and say so in the thread with its identifier. An
|
|
45445
|
+
investigation, incident or project, or anything involving several agents or more than a day:
|
|
45446
|
+
create the task, then open a work channel for it (below), with the person who asked and whoever
|
|
45447
|
+
you need. Name it for what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
|
|
45448
|
+
- **Announce a work channel where the request came in**, in one message: what it is for, who you
|
|
45449
|
+
pulled in, and that you will report back there. When the task is done or cancelled the plugin
|
|
45450
|
+
archives the channel and posts the outcome back where you asked; you do not need to.
|
|
45451
|
+
- **Hand work to another agent by mentioning it** (@cto) in a channel or thread it is in; invite it
|
|
45452
|
+
first if it is not. Give it the context it needs in that message. It replies in the thread.
|
|
45453
|
+
Mention an agent only when you want it to act: every mention starts it.
|
|
45454
|
+
- **Say something before going quiet.** If an answer will take a while, post one line ("On it,
|
|
45455
|
+
checking what shipped this morning") and then do the work.
|
|
45456
|
+
- Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
|
|
45457
|
+
approval.
|
|
45458
|
+
|
|
45459
|
+
## Calls
|
|
45460
|
+
|
|
45461
|
+
Reading (GET):
|
|
45462
|
+
- /mm/channels channels you are in
|
|
45463
|
+
- /mm/channels/{channel}/posts?limit=30 recent posts in a channel (oldest first)
|
|
45464
|
+
- /mm/posts/{postId}/thread a whole thread
|
|
45465
|
+
- /mm/channels/{channel}/members who is in a channel (people and agents)
|
|
45466
|
+
- /mm/search?q=words search messages in your channels
|
|
45467
|
+
- /mm/people?q=name find people and agents by name
|
|
45468
|
+
- /mm/requests/{id} an approval request you made
|
|
45469
|
+
|
|
45470
|
+
Talking (POST, JSON body):
|
|
45471
|
+
- /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
|
|
45472
|
+
- /mm/react {"postId": "...", "emoji": "white_check_mark"}
|
|
45473
|
+
- /mm/dm {"person": "username", "text": "..."} only people allowed to talk to you
|
|
45474
|
+
|
|
45475
|
+
Channels (POST, JSON body):
|
|
45476
|
+
- /mm/channels {"name": "inc checkout 5xx", "purpose": "...", "people": ["username"],
|
|
45477
|
+
"agents": ["cto"], "taskId": "BAD-123 or task id", "private": true}
|
|
45478
|
+
Private by default. A public channel needs approval. With taskId, the channel is tied to the
|
|
45479
|
+
task: its comments and status show up there, and it is archived when the task closes.
|
|
45480
|
+
- /mm/channels/{channel}/invite {"people": [...], "agents": [...]} free on channels you created
|
|
45481
|
+
- /mm/channels/{channel}/update {"purpose": "...", "header": "...", "name": "..."} yours only
|
|
45482
|
+
- /mm/channels/{channel}/archive {} yours only
|
|
45483
|
+
|
|
45484
|
+
{channel} is a channel's id, its URL name (inc-checkout-5xx) or its display name.
|
|
45485
|
+
`;
|
|
45486
|
+
var SKILL = `---
|
|
45487
|
+
name: mattermost
|
|
45488
|
+
description: >
|
|
45489
|
+
Use to talk to people and agents in Mattermost as your own bot: DM someone, post or reply in
|
|
45490
|
+
a channel, read and search channels you are in, hand work to another agent by mentioning it,
|
|
45491
|
+
and open a work channel for an investigation, incident or project tied to a Paperclip task.
|
|
45492
|
+
---
|
|
45493
|
+
|
|
45494
|
+
${HELP}`;
|
|
45495
|
+
|
|
45496
|
+
// src/worker.ts
|
|
45497
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
45498
|
+
|
|
44995
45499
|
// src/bridge.ts
|
|
45500
|
+
var DEFAULT_TURN_LIMIT = 10;
|
|
44996
45501
|
var TYPING_EVERY_MS = 3e3;
|
|
44997
45502
|
var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
|
|
44998
45503
|
var MAX_POST = 15e3;
|
|
@@ -45032,9 +45537,11 @@ var Bridge = class {
|
|
|
45032
45537
|
files;
|
|
45033
45538
|
choiceButtons;
|
|
45034
45539
|
canTalk;
|
|
45540
|
+
turnLimit;
|
|
45035
45541
|
constructor(roster, directory, agents, store, log = () => {
|
|
45036
|
-
}, files = null, choiceButtons = null, canTalk = null) {
|
|
45542
|
+
}, files = null, choiceButtons = null, canTalk = null, turnLimit = DEFAULT_TURN_LIMIT) {
|
|
45037
45543
|
this.files = files;
|
|
45544
|
+
this.turnLimit = turnLimit;
|
|
45038
45545
|
this.canTalk = canTalk;
|
|
45039
45546
|
this.choiceButtons = choiceButtons;
|
|
45040
45547
|
this.roster = roster;
|
|
@@ -45064,6 +45571,42 @@ var Bridge = class {
|
|
|
45064
45571
|
activeThread(agentId) {
|
|
45065
45572
|
return this.active.get(agentId) ?? null;
|
|
45066
45573
|
}
|
|
45574
|
+
/** The agent another agent's post addresses: the first other agent it mentions. */
|
|
45575
|
+
mentioned(evt, author) {
|
|
45576
|
+
if (evt.channelType === "D") return void 0;
|
|
45577
|
+
const roster = this.roster();
|
|
45578
|
+
for (const name of mentions(evt.post.message)) {
|
|
45579
|
+
const member = roster.byUsername(name);
|
|
45580
|
+
if (member && member.agentId !== author.agentId) return member;
|
|
45581
|
+
}
|
|
45582
|
+
return void 0;
|
|
45583
|
+
}
|
|
45584
|
+
/**
|
|
45585
|
+
* Agents may address agents, up to the turn limit per thread; a person posting in the
|
|
45586
|
+
* thread resets it. At the limit the addressed agent says once that it is waiting.
|
|
45587
|
+
*/
|
|
45588
|
+
async resetTurns(rootId) {
|
|
45589
|
+
const turns = await this.store.get(`turns:${rootId}`);
|
|
45590
|
+
if (turns?.n) await this.store.set(`turns:${rootId}`, { n: 0 });
|
|
45591
|
+
}
|
|
45592
|
+
async allowAgentTurn(rootId, fromAgent, member, channelId) {
|
|
45593
|
+
if (!fromAgent) {
|
|
45594
|
+
await this.resetTurns(rootId);
|
|
45595
|
+
return true;
|
|
45596
|
+
}
|
|
45597
|
+
const key = `turns:${rootId}`;
|
|
45598
|
+
const turns = await this.store.get(key) ?? { n: 0 };
|
|
45599
|
+
if (turns.n >= this.turnLimit) {
|
|
45600
|
+
if (!turns.paused) {
|
|
45601
|
+
await this.store.set(key, { ...turns, paused: true });
|
|
45602
|
+
await member.chat.createPost(channelId, rootId, `_Pausing here: agents have traded ${turns.n} messages in this thread. Reply to let us carry on._`).catch(() => {
|
|
45603
|
+
});
|
|
45604
|
+
}
|
|
45605
|
+
return false;
|
|
45606
|
+
}
|
|
45607
|
+
await this.store.set(key, { n: turns.n + 1 });
|
|
45608
|
+
return true;
|
|
45609
|
+
}
|
|
45067
45610
|
target(evt, thread) {
|
|
45068
45611
|
const roster = this.roster();
|
|
45069
45612
|
if (evt.channelType === "D") {
|
|
@@ -45091,15 +45634,21 @@ var Bridge = class {
|
|
|
45091
45634
|
async process(evt, rootId) {
|
|
45092
45635
|
const { post } = evt;
|
|
45093
45636
|
const roster = this.roster();
|
|
45094
|
-
|
|
45637
|
+
const fromAgent = roster.isBot(post.user_id) ? roster.byBotId(post.user_id) : void 0;
|
|
45638
|
+
if (post.type || post.props?.from_webhook === "true" || post.props?.from_paperclip === "true") return;
|
|
45639
|
+
if (post.props?.from_bot === "true" && !fromAgent) return;
|
|
45095
45640
|
const threadKey = `thread:${rootId}`;
|
|
45096
45641
|
const thread = await this.store.get(threadKey);
|
|
45097
|
-
const member = this.target(evt, thread);
|
|
45098
|
-
if (!member)
|
|
45642
|
+
const member = fromAgent ? this.mentioned(evt, fromAgent) : this.target(evt, thread);
|
|
45643
|
+
if (!member) {
|
|
45644
|
+
if (!fromAgent && post.root_id) await this.resetTurns(rootId);
|
|
45645
|
+
return;
|
|
45646
|
+
}
|
|
45647
|
+
if (!await this.allowAgentTurn(rootId, fromAgent, member, post.channel_id)) return;
|
|
45099
45648
|
const text2 = stripMention(post.message, member.username);
|
|
45100
45649
|
const fileIds = this.files ? post.file_ids ?? [] : [];
|
|
45101
45650
|
if (!text2 && !fileIds.length) return;
|
|
45102
|
-
if (this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
|
|
45651
|
+
if (!fromAgent && this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
|
|
45103
45652
|
await this.directory.notify(post.user_id, post.channel_id, post.root_id ?? "", `You don't have access to talk to ${member.agentName} here. Ask a Paperclip admin to add you in the Mattermost plugin's settings.`).catch(() => {
|
|
45104
45653
|
});
|
|
45105
45654
|
return;
|
|
@@ -45130,7 +45679,8 @@ var Bridge = class {
|
|
|
45130
45679
|
const { sender, where, history, text: text2, fileIds } = m;
|
|
45131
45680
|
const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
|
|
45132
45681
|
const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
|
|
45133
|
-
const
|
|
45682
|
+
const fromAgent = this.roster().isBot(post.user_id);
|
|
45683
|
+
const prompt = `${sender.displayName} (@${sender.username}${fromAgent ? ", another agent" : ""}) wrote to you (@${member.username}) in ${where} on Mattermost. Your reply is posted back there, so answer in Markdown. To offer choices, end your reply with one line \`choices: First | Second\` (2 to ${MAX_CHOICES} short options); they are shown as buttons, and the pick comes back as the next message. You can also read channels, post, message people, mention other agents and open work channels in Mattermost: GET <your Paperclip API URL>/api/plugins/mattermost-bridge/api/mm/help shows how.` + (history ? `
|
|
45134
45684
|
|
|
45135
45685
|
Earlier in this thread (oldest first):
|
|
45136
45686
|
${history}
|
|
@@ -45512,6 +46062,9 @@ var AgentBots = class {
|
|
|
45512
46062
|
isBot: (id) => all.some((m) => m.botUserId === id)
|
|
45513
46063
|
};
|
|
45514
46064
|
}
|
|
46065
|
+
all() {
|
|
46066
|
+
return [...this.members.values()];
|
|
46067
|
+
}
|
|
45515
46068
|
/** The Mattermost client of an agent's bot, for posts beyond chat replies. */
|
|
45516
46069
|
clientOf(agentId) {
|
|
45517
46070
|
return this.members.get(agentId)?.client;
|
|
@@ -45654,10 +46207,11 @@ var AgentBots = class {
|
|
|
45654
46207
|
};
|
|
45655
46208
|
|
|
45656
46209
|
// src/manifest.ts
|
|
46210
|
+
var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth: "agent", capability: "api.routes.register" });
|
|
45657
46211
|
var manifest = {
|
|
45658
46212
|
id: "mattermost-bridge",
|
|
45659
46213
|
apiVersion: 1,
|
|
45660
|
-
version: "0.
|
|
46214
|
+
version: "0.14.0",
|
|
45661
46215
|
displayName: "Mattermost",
|
|
45662
46216
|
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.",
|
|
45663
46217
|
author: "Haseeb Azhar",
|
|
@@ -45681,11 +46235,35 @@ var manifest = {
|
|
|
45681
46235
|
"issue.interactions.read",
|
|
45682
46236
|
"issue.interactions.respond",
|
|
45683
46237
|
"issue.comments.create",
|
|
45684
|
-
"issue.comments.create_human_attributed"
|
|
46238
|
+
"issue.comments.create_human_attributed",
|
|
46239
|
+
"issue.comments.read",
|
|
46240
|
+
"api.routes.register",
|
|
46241
|
+
"skills.managed"
|
|
45685
46242
|
],
|
|
45686
46243
|
entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
|
|
45687
46244
|
// Replaces the generated form: approvers picked from members, URL detected and checked.
|
|
45688
46245
|
ui: { slots: [{ type: "settingsPage", id: "mattermost-settings", displayName: "Mattermost", exportName: "SettingsPage" }] },
|
|
46246
|
+
// What agents do in Mattermost as their own bot (src/actions.ts). Agent-only: the host
|
|
46247
|
+
// checks the caller is an agent and scopes it to that agent's company.
|
|
46248
|
+
apiRoutes: [
|
|
46249
|
+
route("help", "GET", "/mm/help"),
|
|
46250
|
+
route("channels", "GET", "/mm/channels"),
|
|
46251
|
+
route("channel-posts", "GET", "/mm/channels/:channel/posts"),
|
|
46252
|
+
route("thread", "GET", "/mm/posts/:postId/thread"),
|
|
46253
|
+
route("channel-members", "GET", "/mm/channels/:channel/members"),
|
|
46254
|
+
route("search", "GET", "/mm/search"),
|
|
46255
|
+
route("people", "GET", "/mm/people"),
|
|
46256
|
+
route("request", "GET", "/mm/requests/:id"),
|
|
46257
|
+
route("post", "POST", "/mm/post"),
|
|
46258
|
+
route("react", "POST", "/mm/react"),
|
|
46259
|
+
route("dm", "POST", "/mm/dm"),
|
|
46260
|
+
route("create-channel", "POST", "/mm/channels"),
|
|
46261
|
+
route("invite", "POST", "/mm/channels/:channel/invite"),
|
|
46262
|
+
route("update-channel", "POST", "/mm/channels/:channel/update"),
|
|
46263
|
+
route("archive", "POST", "/mm/channels/:channel/archive")
|
|
46264
|
+
],
|
|
46265
|
+
// Installed in each company's skill library; the settings page adds it to every agent with a bot.
|
|
46266
|
+
skills: [{ skillKey: "mattermost", displayName: "Mattermost", slug: "mattermost", description: "Talk to people and agents in Mattermost as your own bot.", markdown: SKILL }],
|
|
45689
46267
|
webhooks: [
|
|
45690
46268
|
{ endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
|
|
45691
46269
|
],
|
|
@@ -45735,6 +46313,14 @@ var manifest = {
|
|
|
45735
46313
|
description: "Each person by email, with `agents` listing the agent ids they may talk to (absent: all agents). People outside Paperclip have no userId.",
|
|
45736
46314
|
items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
|
|
45737
46315
|
},
|
|
46316
|
+
agentTurnLimit: {
|
|
46317
|
+
type: "integer",
|
|
46318
|
+
minimum: 1,
|
|
46319
|
+
maximum: 100,
|
|
46320
|
+
default: 10,
|
|
46321
|
+
title: "Agent-to-agent turns",
|
|
46322
|
+
description: "How many messages agents may trade in one thread, mentioning each other, before a person must reply."
|
|
46323
|
+
},
|
|
45738
46324
|
approvers: {
|
|
45739
46325
|
type: "array",
|
|
45740
46326
|
title: "Approvers",
|
|
@@ -45776,7 +46362,10 @@ var Tenant = class {
|
|
|
45776
46362
|
bridge = null;
|
|
45777
46363
|
approvals = null;
|
|
45778
46364
|
cards = null;
|
|
46365
|
+
actions = null;
|
|
46366
|
+
work = null;
|
|
45779
46367
|
key = "";
|
|
46368
|
+
approvers = /* @__PURE__ */ new Map();
|
|
45780
46369
|
timers = [];
|
|
45781
46370
|
agents() {
|
|
45782
46371
|
const companyId = this.companyId;
|
|
@@ -45857,8 +46446,8 @@ var Tenant = class {
|
|
|
45857
46446
|
}
|
|
45858
46447
|
}
|
|
45859
46448
|
for (const team of teams) {
|
|
45860
|
-
const
|
|
45861
|
-
const hit =
|
|
46449
|
+
const list2 = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
|
|
46450
|
+
const hit = list2.find((ch) => ch.display_name.trim().toLowerCase() === wanted.toLowerCase());
|
|
45862
46451
|
if (hit) return hit.id;
|
|
45863
46452
|
}
|
|
45864
46453
|
return null;
|
|
@@ -45882,6 +46471,7 @@ var Tenant = class {
|
|
|
45882
46471
|
const paperclipUrl = typeof config2.paperclipUrl === "string" ? config2.paperclipUrl.trim().replace(/\/+$/, "") : "";
|
|
45883
46472
|
const actionUrl = `${paperclipUrl}/api/plugins/${PLUGIN_KEY}/webhooks/actions`;
|
|
45884
46473
|
const key = createHmac2("sha256", token).update("mattermost-approval-buttons").digest("hex");
|
|
46474
|
+
const canTalk = accessCheck(manager, config2);
|
|
45885
46475
|
const bridge = new Bridge(
|
|
45886
46476
|
() => roster.roster(),
|
|
45887
46477
|
directoryOf(manager),
|
|
@@ -45890,7 +46480,8 @@ var Tenant = class {
|
|
|
45890
46480
|
warn,
|
|
45891
46481
|
diskFiles(),
|
|
45892
46482
|
paperclipUrl ? (where, choices) => this.tag(choiceProps(key, actionUrl, where, choices)) : null,
|
|
45893
|
-
|
|
46483
|
+
canTalk,
|
|
46484
|
+
Number(config2.agentTurnLimit) > 0 ? Number(config2.agentTurnLimit) : DEFAULT_TURN_LIMIT
|
|
45894
46485
|
);
|
|
45895
46486
|
onPosted = (evt) => {
|
|
45896
46487
|
void bridge.handle(evt);
|
|
@@ -45930,7 +46521,41 @@ var Tenant = class {
|
|
|
45930
46521
|
channel: approvalsChannel,
|
|
45931
46522
|
approvers
|
|
45932
46523
|
}) : null;
|
|
45933
|
-
|
|
46524
|
+
const poster = this.poster(manager, roster);
|
|
46525
|
+
const actions = new Actions({
|
|
46526
|
+
bot: (agentId) => roster.clientOf(agentId),
|
|
46527
|
+
manager,
|
|
46528
|
+
agents: () => roster.all().map((m) => ({ agentId: m.agentId, agentName: m.agentName, username: m.username, botUserId: m.botUserId })),
|
|
46529
|
+
canTalk: async (userId, agentId) => !canTalk || canTalk(userId, agentId),
|
|
46530
|
+
origin: (agentId) => bridge.activeThread(agentId),
|
|
46531
|
+
task: async (taskId) => await ctx.issues.get(taskId, companyId),
|
|
46532
|
+
askApproval: async (req) => {
|
|
46533
|
+
if (!paperclipUrl || !approvers.size) throw Object.assign(new Error("this needs a person's approval, and nobody can approve from Mattermost yet (the plugin needs its Paperclip public URL and approvers set)"), { status: 403 });
|
|
46534
|
+
const { message: message2, props } = requestCard(req, actionUrl, key);
|
|
46535
|
+
const targets = [req.origin, approvalsChannel ? await poster.channel(approvalsChannel).then((id) => id && id !== req.origin?.channelId ? { channelId: id, rootId: "" } : null) : null];
|
|
46536
|
+
const postIds = [];
|
|
46537
|
+
for (const where of targets) if (where) postIds.push(await poster.post(req.agentId, where, message2, props));
|
|
46538
|
+
await store.set(`reqcard:${req.id}`, postIds);
|
|
46539
|
+
},
|
|
46540
|
+
store,
|
|
46541
|
+
newId: () => randomUUID2()
|
|
46542
|
+
});
|
|
46543
|
+
const work = new WorkLinks({
|
|
46544
|
+
store,
|
|
46545
|
+
post: async (agentId, channelId, rootId, text2) => {
|
|
46546
|
+
const as = roster.clientOf(agentId) ?? manager;
|
|
46547
|
+
await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: text2, props: FROM_PAPERCLIP });
|
|
46548
|
+
},
|
|
46549
|
+
hasBot: (agentId) => Boolean(roster.clientOf(agentId)),
|
|
46550
|
+
agentName: (agentId) => agentName(agentId),
|
|
46551
|
+
archive: async (channelId) => {
|
|
46552
|
+
await manager.request("DELETE", `/channels/${channelId}`);
|
|
46553
|
+
},
|
|
46554
|
+
channelName: async (channelId) => (await manager.request("GET", `/channels/${channelId}`)).display_name,
|
|
46555
|
+
task: async (issueId) => await ctx.issues.get(issueId, companyId),
|
|
46556
|
+
comments: async (issueId) => (await ctx.issues.listComments(issueId, companyId)).map((c) => ({ id: c.id, body: c.body, authorAgentId: c.authorAgentId }))
|
|
46557
|
+
});
|
|
46558
|
+
Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers });
|
|
45934
46559
|
this.status = `connecting to ${url2} as @${manager.botUsername}`;
|
|
45935
46560
|
detached(() => {
|
|
45936
46561
|
manager.connect(onPosted);
|
|
@@ -45942,6 +46567,7 @@ var Tenant = class {
|
|
|
45942
46567
|
});
|
|
45943
46568
|
void sync();
|
|
45944
46569
|
this.timers.push(setInterval(sync, SYNC_EVERY_MS));
|
|
46570
|
+
void ensureSkill(companyId).catch((err) => warn("could not install the Mattermost skill", { err: errText(err) }));
|
|
45945
46571
|
if (cards) {
|
|
45946
46572
|
const poll = () => cards.poll().catch((err) => warn("decision cards poll failed", { err: errText(err) }));
|
|
45947
46573
|
this.timers.push(setTimeout(poll, 2e4), setInterval(poll, CARDS_EVERY_MS));
|
|
@@ -45953,7 +46579,28 @@ var Tenant = class {
|
|
|
45953
46579
|
this.timers = [];
|
|
45954
46580
|
this.bots?.close();
|
|
45955
46581
|
this.manager?.close();
|
|
45956
|
-
Object.assign(this, { manager: null, bots: null, bridge: null, approvals: null, cards: null });
|
|
46582
|
+
Object.assign(this, { manager: null, bots: null, bridge: null, approvals: null, cards: null, actions: null, work: null });
|
|
46583
|
+
}
|
|
46584
|
+
/** Approve / Reject on an agent's ask-first request (a broadcast, a public channel, an invite). */
|
|
46585
|
+
async decideRequest(req) {
|
|
46586
|
+
const { requestId, action, sig: sig2 } = req.context ?? {};
|
|
46587
|
+
if (!this.actions || !this.manager || !requestId || action !== "approve" && action !== "reject" || !signed(this.key, `req:${requestId}`, action, sig2)) return "This button is not valid.";
|
|
46588
|
+
const email3 = req.user_id ? (await this.manager.request("GET", `/users/${req.user_id}`).catch(() => ({ email: "" }))).email?.toLowerCase() : "";
|
|
46589
|
+
if (!email3 || !this.approvers.has(email3)) return "You are not set up to approve agent requests from Mattermost. Ask an admin to add your email to the plugin's approvers.";
|
|
46590
|
+
const decided = await this.actions.decide(requestId, action === "approve", req.user_name ? `@${req.user_name}` : email3);
|
|
46591
|
+
if (!decided) return "That request is gone.";
|
|
46592
|
+
const { message: message2, props } = requestCard(decided, "", this.key);
|
|
46593
|
+
const store = this.store();
|
|
46594
|
+
for (const postId of await store.get(`reqcard:${requestId}`) ?? []) {
|
|
46595
|
+
await this.manager.request("PUT", `/posts/${postId}/patch`, { message: message2, props }).catch(() => {
|
|
46596
|
+
});
|
|
46597
|
+
}
|
|
46598
|
+
return null;
|
|
46599
|
+
}
|
|
46600
|
+
/** An agent's call to one of the plugin's `/mm/...` routes. */
|
|
46601
|
+
async api(agentId, routeKey, params, input2) {
|
|
46602
|
+
if (!this.actions) return { status: 503, body: { error: `Mattermost is not connected for this company (${this.status})` } };
|
|
46603
|
+
return this.actions.handle(agentId, routeKey, params, input2);
|
|
45957
46604
|
}
|
|
45958
46605
|
async notify(req, notice) {
|
|
45959
46606
|
if (notice && req.user_id && req.channel_id) {
|
|
@@ -45969,6 +46616,10 @@ var Tenant = class {
|
|
|
45969
46616
|
if (this.cards) await this.notify(req, await this.cards.click(req).catch(failed));
|
|
45970
46617
|
return;
|
|
45971
46618
|
}
|
|
46619
|
+
if (kind === "request") {
|
|
46620
|
+
await this.notify(req, await this.decideRequest(req).catch(failed));
|
|
46621
|
+
return;
|
|
46622
|
+
}
|
|
45972
46623
|
if (kind === "choice") {
|
|
45973
46624
|
const choice = req.context;
|
|
45974
46625
|
if (!this.manager || !this.bridge || !req.user_id || !req.post_id || !verifyChoice(this.key, choice)) return;
|
|
@@ -45986,6 +46637,28 @@ var Tenant = class {
|
|
|
45986
46637
|
if (this.approvals) await this.notify(req, await this.approvals.click(req).catch(failed));
|
|
45987
46638
|
}
|
|
45988
46639
|
};
|
|
46640
|
+
function requestCard(r, actionUrl, key) {
|
|
46641
|
+
const pending2 = r.status === "pending";
|
|
46642
|
+
const button = (id, name, style) => ({
|
|
46643
|
+
id,
|
|
46644
|
+
name,
|
|
46645
|
+
style,
|
|
46646
|
+
type: "button",
|
|
46647
|
+
integration: { url: actionUrl, context: { kind: "request", requestId: r.id, action: id, sig: sign(key, `req:${r.id}`, id) } }
|
|
46648
|
+
});
|
|
46649
|
+
const outcome = r.status === "approved" ? ":white_check_mark: Approved" : r.status === "rejected" ? ":x: Rejected" : ":warning: Approved, but it failed";
|
|
46650
|
+
return {
|
|
46651
|
+
message: `**${r.agentName}** asks to ${r.summary}`,
|
|
46652
|
+
props: {
|
|
46653
|
+
attachments: [{
|
|
46654
|
+
color: pending2 ? "#f2a900" : r.status === "approved" ? "#3db887" : "#d24b4e",
|
|
46655
|
+
text: `Needs approval because ${r.why}.`,
|
|
46656
|
+
footer: pending2 ? "Approvers decide here" : `${outcome} by ${r.decidedBy}`,
|
|
46657
|
+
actions: pending2 ? [button("approve", "Approve", "primary"), button("reject", "Reject", "danger")] : []
|
|
46658
|
+
}]
|
|
46659
|
+
}
|
|
46660
|
+
};
|
|
46661
|
+
}
|
|
45989
46662
|
function directoryOf(manager) {
|
|
45990
46663
|
return {
|
|
45991
46664
|
userName: (id) => manager.userName(id),
|
|
@@ -46019,6 +46692,11 @@ function configure(companyId, config2) {
|
|
|
46019
46692
|
});
|
|
46020
46693
|
return run;
|
|
46021
46694
|
}
|
|
46695
|
+
async function ensureSkill(companyId) {
|
|
46696
|
+
let r = await ctx.skills.managed.reconcile("mattermost", companyId);
|
|
46697
|
+
if (r.skill && r.defaultDrift?.changedFiles.length) r = await ctx.skills.managed.reset("mattermost", companyId);
|
|
46698
|
+
return r.skill?.key ?? null;
|
|
46699
|
+
}
|
|
46022
46700
|
async function saveBoot(companyId, config2) {
|
|
46023
46701
|
const all = await ctx.state.get(BOOT) ?? {};
|
|
46024
46702
|
all[companyId] = config2;
|
|
@@ -46038,8 +46716,8 @@ var plugin = definePlugin({
|
|
|
46038
46716
|
const accounts = {};
|
|
46039
46717
|
if (manager) {
|
|
46040
46718
|
for (const team of await manager.request("GET", "/users/me/teams").catch(() => [])) {
|
|
46041
|
-
const
|
|
46042
|
-
channels.push(...
|
|
46719
|
+
const list2 = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
|
|
46720
|
+
channels.push(...list2.filter((ch) => ch.type === "O").map((ch) => ({ name: ch.name, displayName: ch.display_name })));
|
|
46043
46721
|
}
|
|
46044
46722
|
channels = channels.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
46045
46723
|
for (const email3 of emails) {
|
|
@@ -46048,11 +46726,22 @@ var plugin = definePlugin({
|
|
|
46048
46726
|
}
|
|
46049
46727
|
return { detectedUrl: await detectPublicUrl(), connected: Boolean(manager), status: tenant?.status ?? null, versionNote, channels, accounts };
|
|
46050
46728
|
});
|
|
46729
|
+
ctx.actions.register("skill", async (params) => {
|
|
46730
|
+
if (typeof params.companyId !== "string") throw new Error("companyId is required");
|
|
46731
|
+
return { key: await ensureSkill(params.companyId) };
|
|
46732
|
+
});
|
|
46051
46733
|
ctx.actions.register("verify-url", async (params) => verifyUrl(String(params.url ?? ""), PLUGIN_KEY));
|
|
46052
46734
|
const quietly = (what, fn) => fn().catch((err) => ctx.logger.warn(what, { err: errText(err) }));
|
|
46053
46735
|
ctx.events.on("approval.created", (e) => quietly("could not post approval", async () => {
|
|
46054
46736
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.created(e.entityId);
|
|
46055
46737
|
}));
|
|
46738
|
+
ctx.events.on("issue.comment.created", (e) => quietly("could not mirror a task comment", async () => {
|
|
46739
|
+
const commentId = e.payload?.commentId;
|
|
46740
|
+
if (e.entityId && commentId) await tenants.get(e.companyId)?.work?.comment(e.entityId, commentId);
|
|
46741
|
+
}));
|
|
46742
|
+
ctx.events.on("issue.updated", (e) => quietly("could not follow a task update", async () => {
|
|
46743
|
+
if (e.entityId) await tenants.get(e.companyId)?.work?.updated(e.entityId);
|
|
46744
|
+
}));
|
|
46056
46745
|
ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
|
|
46057
46746
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.decided(e.entityId);
|
|
46058
46747
|
}));
|
|
@@ -46080,16 +46769,27 @@ var plugin = definePlugin({
|
|
|
46080
46769
|
const tenant = typeof companyId === "string" ? tenants.get(companyId) : tenants.size === 1 ? [...tenants.values()][0] : void 0;
|
|
46081
46770
|
await tenant?.click(req);
|
|
46082
46771
|
},
|
|
46772
|
+
async onApiRequest(input2) {
|
|
46773
|
+
const agentId = input2.actor.agentId;
|
|
46774
|
+
if (input2.actor.actorType !== "agent" || !agentId) return { status: 403, body: { error: "Only agents call these routes." } };
|
|
46775
|
+
if (input2.routeKey === "help") return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body: HELP };
|
|
46776
|
+
const tenant = tenants.get(input2.companyId);
|
|
46777
|
+
if (!tenant) return { status: 503, body: { error: "Mattermost is not set up for your company." } };
|
|
46778
|
+
const query = Object.fromEntries(Object.entries(input2.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
|
|
46779
|
+
const body = input2.body && typeof input2.body === "object" && !Array.isArray(input2.body) ? input2.body : {};
|
|
46780
|
+
const res = await tenant.api(agentId, input2.routeKey, input2.params, { ...query, ...body });
|
|
46781
|
+
return { status: res.status, body: res.body };
|
|
46782
|
+
},
|
|
46083
46783
|
async onHealth() {
|
|
46084
46784
|
if (!tenants.size) return { status: "error", message: "not configured: set up Mattermost in this plugin's settings for a company" };
|
|
46085
|
-
let
|
|
46785
|
+
let ok2 = true;
|
|
46086
46786
|
const parts = [...tenants.values()].map((t) => {
|
|
46087
46787
|
const live = t.bots?.connected ?? 0;
|
|
46088
46788
|
const total = t.bots?.size ?? 0;
|
|
46089
|
-
if (!t.manager?.connected || live !== total || !total)
|
|
46789
|
+
if (!t.manager?.connected || live !== total || !total) ok2 = false;
|
|
46090
46790
|
return `${t.status}; ${live}/${total} agent bots connected`;
|
|
46091
46791
|
});
|
|
46092
|
-
return { status:
|
|
46792
|
+
return { status: ok2 ? "ok" : "degraded", message: parts.join(" | ") + (versionNote ? ` | Note: ${versionNote}` : "") };
|
|
46093
46793
|
},
|
|
46094
46794
|
async onShutdown() {
|
|
46095
46795
|
for (const t of tenants.values()) t.stop();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "paperclip-plugin-mattermost",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Paperclip agents in Mattermost: a bot per agent, live progress, files, approval and decision buttons, and per-person access, for each Paperclip company.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|