paperclip-plugin-mattermost 0.11.3 → 0.13.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 +6 -3
- package/dist/manifest.js +32 -2
- package/dist/ui/index.js +11 -0
- package/dist/worker.js +737 -121
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
|
|
|
5
5
|
## What it does
|
|
6
6
|
|
|
7
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
|
|
8
|
+
- **You see it working.** The agent's bot reacts 👀 to your message straight away, and shows Mattermost's "is typing…" indicator until its answer arrives.
|
|
9
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
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
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.
|
|
@@ -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. `GET /api/plugins/mattermost-bridge/api/mm/help` tells an agent how.
|
|
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
|
|
@@ -60,7 +64,6 @@ Or install it from **Settings → Plugins** in Paperclip.
|
|
|
60
64
|
- **Multi-choice questions** and free-text "Other" answers are answered in Paperclip; the card links there.
|
|
61
65
|
- **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
66
|
- **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
67
|
|
|
65
68
|
## Development
|
|
66
69
|
|
|
@@ -79,7 +82,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
|
|
|
79
82
|
Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
|
|
80
83
|
|
|
81
84
|
```bash
|
|
82
|
-
git tag v0.
|
|
85
|
+
git tag -a v0.13.0 -m v0.13.0 && git push origin main v0.13.0
|
|
83
86
|
```
|
|
84
87
|
|
|
85
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,9 @@
|
|
|
1
1
|
// src/manifest.ts
|
|
2
|
+
var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent", capability: "api.routes.register" });
|
|
2
3
|
var manifest = {
|
|
3
4
|
id: "mattermost-bridge",
|
|
4
5
|
apiVersion: 1,
|
|
5
|
-
version: "0.
|
|
6
|
+
version: "0.13.0",
|
|
6
7
|
displayName: "Mattermost",
|
|
7
8
|
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
9
|
author: "Haseeb Azhar",
|
|
@@ -26,11 +27,32 @@ var manifest = {
|
|
|
26
27
|
"issue.interactions.read",
|
|
27
28
|
"issue.interactions.respond",
|
|
28
29
|
"issue.comments.create",
|
|
29
|
-
"issue.comments.create_human_attributed"
|
|
30
|
+
"issue.comments.create_human_attributed",
|
|
31
|
+
"issue.comments.read",
|
|
32
|
+
"api.routes.register"
|
|
30
33
|
],
|
|
31
34
|
entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
|
|
32
35
|
// Replaces the generated form: approvers picked from members, URL detected and checked.
|
|
33
36
|
ui: { slots: [{ type: "settingsPage", id: "mattermost-settings", displayName: "Mattermost", exportName: "SettingsPage" }] },
|
|
37
|
+
// What agents do in Mattermost as their own bot (src/actions.ts). Agent-only: the host
|
|
38
|
+
// checks the caller is an agent and scopes it to that agent's company.
|
|
39
|
+
apiRoutes: [
|
|
40
|
+
route("help", "GET", "/mm/help"),
|
|
41
|
+
route("channels", "GET", "/mm/channels"),
|
|
42
|
+
route("channel-posts", "GET", "/mm/channels/:channel/posts"),
|
|
43
|
+
route("thread", "GET", "/mm/posts/:postId/thread"),
|
|
44
|
+
route("channel-members", "GET", "/mm/channels/:channel/members"),
|
|
45
|
+
route("search", "GET", "/mm/search"),
|
|
46
|
+
route("people", "GET", "/mm/people"),
|
|
47
|
+
route("request", "GET", "/mm/requests/:id"),
|
|
48
|
+
route("post", "POST", "/mm/post"),
|
|
49
|
+
route("react", "POST", "/mm/react"),
|
|
50
|
+
route("dm", "POST", "/mm/dm"),
|
|
51
|
+
route("create-channel", "POST", "/mm/channels"),
|
|
52
|
+
route("invite", "POST", "/mm/channels/:channel/invite"),
|
|
53
|
+
route("update-channel", "POST", "/mm/channels/:channel/update"),
|
|
54
|
+
route("archive", "POST", "/mm/channels/:channel/archive")
|
|
55
|
+
],
|
|
34
56
|
webhooks: [
|
|
35
57
|
{ endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
|
|
36
58
|
],
|
|
@@ -80,6 +102,14 @@ var manifest = {
|
|
|
80
102
|
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
103
|
items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
|
|
82
104
|
},
|
|
105
|
+
agentTurnLimit: {
|
|
106
|
+
type: "integer",
|
|
107
|
+
minimum: 1,
|
|
108
|
+
maximum: 100,
|
|
109
|
+
default: 10,
|
|
110
|
+
title: "Agent-to-agent turns",
|
|
111
|
+
description: "How many messages agents may trade in one thread, mentioning each other, before a person must reply."
|
|
112
|
+
},
|
|
83
113
|
approvers: {
|
|
84
114
|
type: "array",
|
|
85
115
|
title: "Approvers",
|
package/dist/ui/index.js
CHANGED
|
@@ -323,6 +323,17 @@ function SettingsPage({ context }) {
|
|
|
323
323
|
/* @__PURE__ */ jsx("option", { value: "", children: "None: only in the agent's thread" }),
|
|
324
324
|
setup.data.channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
|
|
325
325
|
] }) : /* @__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__ */ 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(
|
|
327
|
+
"input",
|
|
328
|
+
{
|
|
329
|
+
style: { ...s.input, maxWidth: 120 },
|
|
330
|
+
type: "number",
|
|
331
|
+
min: 1,
|
|
332
|
+
max: 100,
|
|
333
|
+
value: String(config.agentTurnLimit ?? 10),
|
|
334
|
+
onChange: (e) => set({ agentTurnLimit: Math.min(100, Math.max(1, Math.round(Number(e.target.value) || 10))) })
|
|
335
|
+
}
|
|
336
|
+
) }),
|
|
326
337
|
/* @__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: [
|
|
327
338
|
/* @__PURE__ */ jsx(
|
|
328
339
|
Modes,
|
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,57 +44992,501 @@ function chosenProps(choice, who) {
|
|
|
44992
44992
|
return { attachments: [{ text: `${who} chose **${choice}**`, actions: [] }] };
|
|
44993
44993
|
}
|
|
44994
44994
|
|
|
44995
|
-
// src/
|
|
44996
|
-
var
|
|
44997
|
-
var
|
|
44998
|
-
var
|
|
44999
|
-
|
|
45000
|
-
|
|
45001
|
-
|
|
45002
|
-
|
|
45003
|
-
|
|
45004
|
-
|
|
45005
|
-
|
|
45006
|
-
|
|
45007
|
-
|
|
45008
|
-
|
|
45009
|
-
|
|
45010
|
-
|
|
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")) {
|
|
45011
45088
|
try {
|
|
45012
|
-
|
|
45089
|
+
return await bot.request("GET", `/teams/${team.id}/channels/name/${encodeURIComponent(channelSlug(want))}`);
|
|
45013
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);
|
|
45014
45228
|
continue;
|
|
45015
45229
|
}
|
|
45016
|
-
|
|
45017
|
-
|
|
45018
|
-
|
|
45019
|
-
|
|
45020
|
-
|
|
45021
|
-
|
|
45022
|
-
|
|
45023
|
-
|
|
45024
|
-
}
|
|
45025
|
-
}
|
|
45026
|
-
return changed;
|
|
45027
|
-
}
|
|
45028
|
-
render() {
|
|
45029
|
-
const head = THINKING;
|
|
45030
|
-
const all = [...this.steps.values()];
|
|
45031
|
-
if (!all.length) return head;
|
|
45032
|
-
const shown = all.slice(-MAX_STEPS);
|
|
45033
|
-
const lines = shown.map((s) => {
|
|
45034
|
-
const mark = s.status === "completed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\u2026";
|
|
45035
|
-
const label2 = s.label.replace(/\s+/g, " ").trim();
|
|
45036
|
-
return `- ${mark} \`${(label2.length > MAX_LABEL2 ? label2.slice(0, MAX_LABEL2) + "\u2026" : label2).replace(/`/g, "'")}\``;
|
|
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(() => {
|
|
45037
45238
|
});
|
|
45038
|
-
|
|
45039
|
-
|
|
45040
|
-
|
|
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
|
+
}
|
|
45041
45422
|
}
|
|
45042
45423
|
};
|
|
45043
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. Call these with your
|
|
45430
|
+
Paperclip API credentials, the same way you call the Paperclip API:
|
|
45431
|
+
|
|
45432
|
+
<your Paperclip API URL>/api/plugins/mattermost-bridge/api/mm/...
|
|
45433
|
+
|
|
45434
|
+
Answers are JSON. A 202 with "waiting_for_approval" means a person has been asked to approve;
|
|
45435
|
+
tell whoever you are working with and carry on. A 403 says why something is not allowed.
|
|
45436
|
+
|
|
45437
|
+
## How to work
|
|
45438
|
+
|
|
45439
|
+
- **Pick the size of the work.** A quick question: answer in the thread. A task: create a Paperclip
|
|
45440
|
+
task (assign it to yourself or delegate it) and say so in the thread with its identifier. An
|
|
45441
|
+
investigation, incident or project, or anything involving several agents or more than a day:
|
|
45442
|
+
create the task, then open a work channel for it (below), with the person who asked and whoever
|
|
45443
|
+
you need. Name it for what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
|
|
45444
|
+
- **Announce a work channel where the request came in**, in one message: what it is for, who you
|
|
45445
|
+
pulled in, and that you will report back there. When the task is done or cancelled the plugin
|
|
45446
|
+
archives the channel and posts the outcome back where you asked; you do not need to.
|
|
45447
|
+
- **Hand work to another agent by mentioning it** (@cto) in a channel or thread it is in; invite it
|
|
45448
|
+
first if it is not. Give it the context it needs in that message. It replies in the thread.
|
|
45449
|
+
Mention an agent only when you want it to act: every mention starts it.
|
|
45450
|
+
- **Say something before going quiet.** If an answer will take a while, post one line ("On it,
|
|
45451
|
+
checking what shipped this morning") and then do the work.
|
|
45452
|
+
- Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
|
|
45453
|
+
approval.
|
|
45454
|
+
|
|
45455
|
+
## Calls
|
|
45456
|
+
|
|
45457
|
+
Reading (GET):
|
|
45458
|
+
- /mm/channels channels you are in
|
|
45459
|
+
- /mm/channels/{channel}/posts?limit=30 recent posts in a channel (oldest first)
|
|
45460
|
+
- /mm/posts/{postId}/thread a whole thread
|
|
45461
|
+
- /mm/channels/{channel}/members who is in a channel (people and agents)
|
|
45462
|
+
- /mm/search?q=words search messages in your channels
|
|
45463
|
+
- /mm/people?q=name find people and agents by name
|
|
45464
|
+
- /mm/requests/{id} an approval request you made
|
|
45465
|
+
|
|
45466
|
+
Talking (POST, JSON body):
|
|
45467
|
+
- /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
|
|
45468
|
+
- /mm/react {"postId": "...", "emoji": "white_check_mark"}
|
|
45469
|
+
- /mm/dm {"person": "username", "text": "..."} only people allowed to talk to you
|
|
45470
|
+
|
|
45471
|
+
Channels (POST, JSON body):
|
|
45472
|
+
- /mm/channels {"name": "inc checkout 5xx", "purpose": "...", "people": ["username"],
|
|
45473
|
+
"agents": ["cto"], "taskId": "BAD-123 or task id", "private": true}
|
|
45474
|
+
Private by default. A public channel needs approval. With taskId, the channel is tied to the
|
|
45475
|
+
task: its comments and status show up there, and it is archived when the task closes.
|
|
45476
|
+
- /mm/channels/{channel}/invite {"people": [...], "agents": [...]} free on channels you created
|
|
45477
|
+
- /mm/channels/{channel}/update {"purpose": "...", "header": "...", "name": "..."} yours only
|
|
45478
|
+
- /mm/channels/{channel}/archive {} yours only
|
|
45479
|
+
|
|
45480
|
+
{channel} is a channel's id, its URL name (inc-checkout-5xx) or its display name.
|
|
45481
|
+
`;
|
|
45482
|
+
|
|
45483
|
+
// src/worker.ts
|
|
45484
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
45485
|
+
|
|
45044
45486
|
// src/bridge.ts
|
|
45045
|
-
var
|
|
45487
|
+
var DEFAULT_TURN_LIMIT = 10;
|
|
45488
|
+
var TYPING_EVERY_MS = 3e3;
|
|
45489
|
+
var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
|
|
45046
45490
|
var MAX_POST = 15e3;
|
|
45047
45491
|
var MAX_HISTORY = 8e3;
|
|
45048
45492
|
var MENTION = /(?:^|[^\w@])@([a-z0-9][a-z0-9._-]*)/gi;
|
|
@@ -45080,9 +45524,11 @@ var Bridge = class {
|
|
|
45080
45524
|
files;
|
|
45081
45525
|
choiceButtons;
|
|
45082
45526
|
canTalk;
|
|
45527
|
+
turnLimit;
|
|
45083
45528
|
constructor(roster, directory, agents, store, log = () => {
|
|
45084
|
-
}, files = null, choiceButtons = null, canTalk = null) {
|
|
45529
|
+
}, files = null, choiceButtons = null, canTalk = null, turnLimit = DEFAULT_TURN_LIMIT) {
|
|
45085
45530
|
this.files = files;
|
|
45531
|
+
this.turnLimit = turnLimit;
|
|
45086
45532
|
this.canTalk = canTalk;
|
|
45087
45533
|
this.choiceButtons = choiceButtons;
|
|
45088
45534
|
this.roster = roster;
|
|
@@ -45112,6 +45558,42 @@ var Bridge = class {
|
|
|
45112
45558
|
activeThread(agentId) {
|
|
45113
45559
|
return this.active.get(agentId) ?? null;
|
|
45114
45560
|
}
|
|
45561
|
+
/** The agent another agent's post addresses: the first other agent it mentions. */
|
|
45562
|
+
mentioned(evt, author) {
|
|
45563
|
+
if (evt.channelType === "D") return void 0;
|
|
45564
|
+
const roster = this.roster();
|
|
45565
|
+
for (const name of mentions(evt.post.message)) {
|
|
45566
|
+
const member = roster.byUsername(name);
|
|
45567
|
+
if (member && member.agentId !== author.agentId) return member;
|
|
45568
|
+
}
|
|
45569
|
+
return void 0;
|
|
45570
|
+
}
|
|
45571
|
+
/**
|
|
45572
|
+
* Agents may address agents, up to the turn limit per thread; a person posting in the
|
|
45573
|
+
* thread resets it. At the limit the addressed agent says once that it is waiting.
|
|
45574
|
+
*/
|
|
45575
|
+
async resetTurns(rootId) {
|
|
45576
|
+
const turns = await this.store.get(`turns:${rootId}`);
|
|
45577
|
+
if (turns?.n) await this.store.set(`turns:${rootId}`, { n: 0 });
|
|
45578
|
+
}
|
|
45579
|
+
async allowAgentTurn(rootId, fromAgent, member, channelId) {
|
|
45580
|
+
if (!fromAgent) {
|
|
45581
|
+
await this.resetTurns(rootId);
|
|
45582
|
+
return true;
|
|
45583
|
+
}
|
|
45584
|
+
const key = `turns:${rootId}`;
|
|
45585
|
+
const turns = await this.store.get(key) ?? { n: 0 };
|
|
45586
|
+
if (turns.n >= this.turnLimit) {
|
|
45587
|
+
if (!turns.paused) {
|
|
45588
|
+
await this.store.set(key, { ...turns, paused: true });
|
|
45589
|
+
await member.chat.createPost(channelId, rootId, `_Pausing here: agents have traded ${turns.n} messages in this thread. Reply to let us carry on._`).catch(() => {
|
|
45590
|
+
});
|
|
45591
|
+
}
|
|
45592
|
+
return false;
|
|
45593
|
+
}
|
|
45594
|
+
await this.store.set(key, { n: turns.n + 1 });
|
|
45595
|
+
return true;
|
|
45596
|
+
}
|
|
45115
45597
|
target(evt, thread) {
|
|
45116
45598
|
const roster = this.roster();
|
|
45117
45599
|
if (evt.channelType === "D") {
|
|
@@ -45128,7 +45610,7 @@ var Bridge = class {
|
|
|
45128
45610
|
const roster = this.roster();
|
|
45129
45611
|
const lines = [];
|
|
45130
45612
|
for (const p of await this.directory.threadPosts(rootId)) {
|
|
45131
|
-
if (p.id === currentId || p.type || !p.message.trim() || p.message
|
|
45613
|
+
if (p.id === currentId || p.type || !p.message.trim() || OLD_PLACEHOLDER.test(p.message)) continue;
|
|
45132
45614
|
const who = p.user_id === me.botUserId ? "You" : `@${roster.byBotId(p.user_id)?.username ?? (await this.directory.userName(p.user_id)).username}`;
|
|
45133
45615
|
lines.push(`${who}: ${p.message}`);
|
|
45134
45616
|
}
|
|
@@ -45139,15 +45621,21 @@ var Bridge = class {
|
|
|
45139
45621
|
async process(evt, rootId) {
|
|
45140
45622
|
const { post } = evt;
|
|
45141
45623
|
const roster = this.roster();
|
|
45142
|
-
|
|
45624
|
+
const fromAgent = roster.isBot(post.user_id) ? roster.byBotId(post.user_id) : void 0;
|
|
45625
|
+
if (post.type || post.props?.from_webhook === "true" || post.props?.from_paperclip === "true") return;
|
|
45626
|
+
if (post.props?.from_bot === "true" && !fromAgent) return;
|
|
45143
45627
|
const threadKey = `thread:${rootId}`;
|
|
45144
45628
|
const thread = await this.store.get(threadKey);
|
|
45145
|
-
const member = this.target(evt, thread);
|
|
45146
|
-
if (!member)
|
|
45629
|
+
const member = fromAgent ? this.mentioned(evt, fromAgent) : this.target(evt, thread);
|
|
45630
|
+
if (!member) {
|
|
45631
|
+
if (!fromAgent && post.root_id) await this.resetTurns(rootId);
|
|
45632
|
+
return;
|
|
45633
|
+
}
|
|
45634
|
+
if (!await this.allowAgentTurn(rootId, fromAgent, member, post.channel_id)) return;
|
|
45147
45635
|
const text2 = stripMention(post.message, member.username);
|
|
45148
45636
|
const fileIds = this.files ? post.file_ids ?? [] : [];
|
|
45149
45637
|
if (!text2 && !fileIds.length) return;
|
|
45150
|
-
if (this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
|
|
45638
|
+
if (!fromAgent && this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
|
|
45151
45639
|
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(() => {
|
|
45152
45640
|
});
|
|
45153
45641
|
return;
|
|
@@ -45159,13 +45647,27 @@ var Bridge = class {
|
|
|
45159
45647
|
const sender = await this.directory.userName(post.user_id);
|
|
45160
45648
|
const where = isDirect ? "a direct message" : `#${evt.channelName}`;
|
|
45161
45649
|
const history = post.root_id ? await this.history(post.root_id, post.id, member) : "";
|
|
45162
|
-
const
|
|
45163
|
-
|
|
45164
|
-
|
|
45165
|
-
|
|
45650
|
+
const typing = () => {
|
|
45651
|
+
void member.chat.typing(post.channel_id, rootId).catch(() => {
|
|
45652
|
+
});
|
|
45653
|
+
void member.chat.typing(post.channel_id, "").catch(() => {
|
|
45654
|
+
});
|
|
45655
|
+
};
|
|
45656
|
+
typing();
|
|
45657
|
+
const typingTimer = setInterval(typing, TYPING_EVERY_MS);
|
|
45658
|
+
try {
|
|
45659
|
+
await this.answer(evt, member, rootId, threadKey, thread, { sender, where, history, text: text2, fileIds });
|
|
45660
|
+
} finally {
|
|
45661
|
+
clearInterval(typingTimer);
|
|
45662
|
+
}
|
|
45663
|
+
}
|
|
45664
|
+
async answer(evt, member, rootId, threadKey, thread, m) {
|
|
45665
|
+
const { post } = evt;
|
|
45666
|
+
const { sender, where, history, text: text2, fileIds } = m;
|
|
45166
45667
|
const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
|
|
45167
45668
|
const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
|
|
45168
|
-
const
|
|
45669
|
+
const fromAgent = this.roster().isBot(post.user_id);
|
|
45670
|
+
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 ? `
|
|
45169
45671
|
|
|
45170
45672
|
Earlier in this thread (oldest first):
|
|
45171
45673
|
${history}
|
|
@@ -45178,23 +45680,6 @@ Attached files, saved on disk (read them at these paths):
|
|
|
45178
45680
|
${attached.join("\n")}` : "") + (outbox ? `
|
|
45179
45681
|
|
|
45180
45682
|
To send files back with your reply, save them in ${outbox}` : "");
|
|
45181
|
-
let timer = null;
|
|
45182
|
-
let edit = Promise.resolve();
|
|
45183
|
-
let finished = false;
|
|
45184
|
-
const onOutput = (chunk) => {
|
|
45185
|
-
if (finished || !progress.feed(chunk) || timer) return;
|
|
45186
|
-
timer = setTimeout(() => {
|
|
45187
|
-
timer = null;
|
|
45188
|
-
if (finished) return;
|
|
45189
|
-
edit = edit.then(() => member.chat.patchPost(placeholder.id, progress.render())).catch(() => {
|
|
45190
|
-
});
|
|
45191
|
-
}, PROGRESS_EVERY_MS);
|
|
45192
|
-
};
|
|
45193
|
-
const settle2 = async () => {
|
|
45194
|
-
finished = true;
|
|
45195
|
-
if (timer) clearTimeout(timer);
|
|
45196
|
-
await edit;
|
|
45197
|
-
};
|
|
45198
45683
|
let reply;
|
|
45199
45684
|
const here = { channelId: post.channel_id, rootId };
|
|
45200
45685
|
this.active.set(member.agentId, here);
|
|
@@ -45202,21 +45687,19 @@ To send files back with your reply, save them in ${outbox}` : "");
|
|
|
45202
45687
|
let sessionId = thread && thread.agentId === member.agentId ? thread.sessionId : null;
|
|
45203
45688
|
try {
|
|
45204
45689
|
if (!sessionId) throw new Error("no session yet");
|
|
45205
|
-
reply = await this.agents.ask(sessionId, prompt
|
|
45690
|
+
reply = await this.agents.ask(sessionId, prompt);
|
|
45206
45691
|
} catch (first2) {
|
|
45207
45692
|
if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(first2) });
|
|
45208
45693
|
sessionId = await this.agents.createSession(member.agentId, `mattermost:${rootId}`);
|
|
45209
45694
|
await this.store.set(threadKey, { agentId: member.agentId, sessionId });
|
|
45210
|
-
reply = await this.agents.ask(sessionId, prompt
|
|
45695
|
+
reply = await this.agents.ask(sessionId, prompt);
|
|
45211
45696
|
}
|
|
45212
45697
|
} catch (err) {
|
|
45213
45698
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45214
|
-
await
|
|
45215
|
-
await member.chat.patchPost(placeholder.id, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
|
|
45699
|
+
await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
|
|
45216
45700
|
return;
|
|
45217
45701
|
}
|
|
45218
45702
|
if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
|
|
45219
|
-
await settle2();
|
|
45220
45703
|
const { text: body, choices } = extractChoices(reply ?? "");
|
|
45221
45704
|
const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
|
|
45222
45705
|
const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
|
|
@@ -45224,7 +45707,7 @@ To send files back with your reply, save them in ${outbox}` : "");
|
|
|
45224
45707
|
|
|
45225
45708
|
${choices.map((c, i) => `${i + 1}. ${c}`).join("\n")}` : body;
|
|
45226
45709
|
const [first, ...more] = splitMessage(shown.trim() || `_${member.agentName} finished without a reply._`);
|
|
45227
|
-
await member.chat.
|
|
45710
|
+
await member.chat.createPost(post.channel_id, rootId, first);
|
|
45228
45711
|
for (const part of more) await member.chat.createPost(post.channel_id, rootId, part);
|
|
45229
45712
|
if (buttons) await member.chat.createPost(post.channel_id, rootId, "", buttons);
|
|
45230
45713
|
if (outbox) {
|
|
@@ -45316,9 +45799,6 @@ var MattermostClient = class {
|
|
|
45316
45799
|
async react(postId, emoji3) {
|
|
45317
45800
|
await this.request("POST", "/reactions", { user_id: this.botUserId, post_id: postId, emoji_name: emoji3 });
|
|
45318
45801
|
}
|
|
45319
|
-
async patchPost(postId, message2) {
|
|
45320
|
-
await this.request("PUT", `/posts/${postId}/patch`, { message: message2 });
|
|
45321
|
-
}
|
|
45322
45802
|
async typing(channelId, rootId) {
|
|
45323
45803
|
await this.request("POST", "/users/me/typing", { channel_id: channelId, parent_id: rootId });
|
|
45324
45804
|
}
|
|
@@ -45569,6 +46049,9 @@ var AgentBots = class {
|
|
|
45569
46049
|
isBot: (id) => all.some((m) => m.botUserId === id)
|
|
45570
46050
|
};
|
|
45571
46051
|
}
|
|
46052
|
+
all() {
|
|
46053
|
+
return [...this.members.values()];
|
|
46054
|
+
}
|
|
45572
46055
|
/** The Mattermost client of an agent's bot, for posts beyond chat replies. */
|
|
45573
46056
|
clientOf(agentId) {
|
|
45574
46057
|
return this.members.get(agentId)?.client;
|
|
@@ -45711,10 +46194,11 @@ var AgentBots = class {
|
|
|
45711
46194
|
};
|
|
45712
46195
|
|
|
45713
46196
|
// src/manifest.ts
|
|
46197
|
+
var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth: "agent", capability: "api.routes.register" });
|
|
45714
46198
|
var manifest = {
|
|
45715
46199
|
id: "mattermost-bridge",
|
|
45716
46200
|
apiVersion: 1,
|
|
45717
|
-
version: "0.
|
|
46201
|
+
version: "0.13.0",
|
|
45718
46202
|
displayName: "Mattermost",
|
|
45719
46203
|
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.",
|
|
45720
46204
|
author: "Haseeb Azhar",
|
|
@@ -45738,11 +46222,32 @@ var manifest = {
|
|
|
45738
46222
|
"issue.interactions.read",
|
|
45739
46223
|
"issue.interactions.respond",
|
|
45740
46224
|
"issue.comments.create",
|
|
45741
|
-
"issue.comments.create_human_attributed"
|
|
46225
|
+
"issue.comments.create_human_attributed",
|
|
46226
|
+
"issue.comments.read",
|
|
46227
|
+
"api.routes.register"
|
|
45742
46228
|
],
|
|
45743
46229
|
entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
|
|
45744
46230
|
// Replaces the generated form: approvers picked from members, URL detected and checked.
|
|
45745
46231
|
ui: { slots: [{ type: "settingsPage", id: "mattermost-settings", displayName: "Mattermost", exportName: "SettingsPage" }] },
|
|
46232
|
+
// What agents do in Mattermost as their own bot (src/actions.ts). Agent-only: the host
|
|
46233
|
+
// checks the caller is an agent and scopes it to that agent's company.
|
|
46234
|
+
apiRoutes: [
|
|
46235
|
+
route("help", "GET", "/mm/help"),
|
|
46236
|
+
route("channels", "GET", "/mm/channels"),
|
|
46237
|
+
route("channel-posts", "GET", "/mm/channels/:channel/posts"),
|
|
46238
|
+
route("thread", "GET", "/mm/posts/:postId/thread"),
|
|
46239
|
+
route("channel-members", "GET", "/mm/channels/:channel/members"),
|
|
46240
|
+
route("search", "GET", "/mm/search"),
|
|
46241
|
+
route("people", "GET", "/mm/people"),
|
|
46242
|
+
route("request", "GET", "/mm/requests/:id"),
|
|
46243
|
+
route("post", "POST", "/mm/post"),
|
|
46244
|
+
route("react", "POST", "/mm/react"),
|
|
46245
|
+
route("dm", "POST", "/mm/dm"),
|
|
46246
|
+
route("create-channel", "POST", "/mm/channels"),
|
|
46247
|
+
route("invite", "POST", "/mm/channels/:channel/invite"),
|
|
46248
|
+
route("update-channel", "POST", "/mm/channels/:channel/update"),
|
|
46249
|
+
route("archive", "POST", "/mm/channels/:channel/archive")
|
|
46250
|
+
],
|
|
45746
46251
|
webhooks: [
|
|
45747
46252
|
{ endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
|
|
45748
46253
|
],
|
|
@@ -45792,6 +46297,14 @@ var manifest = {
|
|
|
45792
46297
|
description: "Each person by email, with `agents` listing the agent ids they may talk to (absent: all agents). People outside Paperclip have no userId.",
|
|
45793
46298
|
items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
|
|
45794
46299
|
},
|
|
46300
|
+
agentTurnLimit: {
|
|
46301
|
+
type: "integer",
|
|
46302
|
+
minimum: 1,
|
|
46303
|
+
maximum: 100,
|
|
46304
|
+
default: 10,
|
|
46305
|
+
title: "Agent-to-agent turns",
|
|
46306
|
+
description: "How many messages agents may trade in one thread, mentioning each other, before a person must reply."
|
|
46307
|
+
},
|
|
45795
46308
|
approvers: {
|
|
45796
46309
|
type: "array",
|
|
45797
46310
|
title: "Approvers",
|
|
@@ -45833,7 +46346,10 @@ var Tenant = class {
|
|
|
45833
46346
|
bridge = null;
|
|
45834
46347
|
approvals = null;
|
|
45835
46348
|
cards = null;
|
|
46349
|
+
actions = null;
|
|
46350
|
+
work = null;
|
|
45836
46351
|
key = "";
|
|
46352
|
+
approvers = /* @__PURE__ */ new Map();
|
|
45837
46353
|
timers = [];
|
|
45838
46354
|
agents() {
|
|
45839
46355
|
const companyId = this.companyId;
|
|
@@ -45842,14 +46358,13 @@ var Tenant = class {
|
|
|
45842
46358
|
const key = `plugin:${PLUGIN_KEY}:session:${taskKey}`;
|
|
45843
46359
|
return (await ctx.agents.sessions.create(agentId, companyId, { taskKey: key, reason: "Mattermost thread" })).sessionId;
|
|
45844
46360
|
},
|
|
45845
|
-
ask(sessionId, prompt
|
|
46361
|
+
ask(sessionId, prompt) {
|
|
45846
46362
|
return new Promise((resolve, reject) => {
|
|
45847
46363
|
const timer = setTimeout(() => reject(new Error("no reply after 30 minutes")), ASK_TIMEOUT_MS);
|
|
45848
46364
|
ctx.agents.sessions.sendMessage(sessionId, companyId, {
|
|
45849
46365
|
prompt,
|
|
45850
46366
|
reason: "Mattermost message",
|
|
45851
46367
|
onEvent: (e) => {
|
|
45852
|
-
if (e.eventType === "chunk" && e.stream === "stdout" && e.message) onOutput?.(e.message);
|
|
45853
46368
|
if (e.eventType === "done") {
|
|
45854
46369
|
clearTimeout(timer);
|
|
45855
46370
|
resolve(e.message);
|
|
@@ -45915,8 +46430,8 @@ var Tenant = class {
|
|
|
45915
46430
|
}
|
|
45916
46431
|
}
|
|
45917
46432
|
for (const team of teams) {
|
|
45918
|
-
const
|
|
45919
|
-
const hit =
|
|
46433
|
+
const list2 = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
|
|
46434
|
+
const hit = list2.find((ch) => ch.display_name.trim().toLowerCase() === wanted.toLowerCase());
|
|
45920
46435
|
if (hit) return hit.id;
|
|
45921
46436
|
}
|
|
45922
46437
|
return null;
|
|
@@ -45940,6 +46455,7 @@ var Tenant = class {
|
|
|
45940
46455
|
const paperclipUrl = typeof config2.paperclipUrl === "string" ? config2.paperclipUrl.trim().replace(/\/+$/, "") : "";
|
|
45941
46456
|
const actionUrl = `${paperclipUrl}/api/plugins/${PLUGIN_KEY}/webhooks/actions`;
|
|
45942
46457
|
const key = createHmac2("sha256", token).update("mattermost-approval-buttons").digest("hex");
|
|
46458
|
+
const canTalk = accessCheck(manager, config2);
|
|
45943
46459
|
const bridge = new Bridge(
|
|
45944
46460
|
() => roster.roster(),
|
|
45945
46461
|
directoryOf(manager),
|
|
@@ -45948,7 +46464,8 @@ var Tenant = class {
|
|
|
45948
46464
|
warn,
|
|
45949
46465
|
diskFiles(),
|
|
45950
46466
|
paperclipUrl ? (where, choices) => this.tag(choiceProps(key, actionUrl, where, choices)) : null,
|
|
45951
|
-
|
|
46467
|
+
canTalk,
|
|
46468
|
+
Number(config2.agentTurnLimit) > 0 ? Number(config2.agentTurnLimit) : DEFAULT_TURN_LIMIT
|
|
45952
46469
|
);
|
|
45953
46470
|
onPosted = (evt) => {
|
|
45954
46471
|
void bridge.handle(evt);
|
|
@@ -45988,7 +46505,41 @@ var Tenant = class {
|
|
|
45988
46505
|
channel: approvalsChannel,
|
|
45989
46506
|
approvers
|
|
45990
46507
|
}) : null;
|
|
45991
|
-
|
|
46508
|
+
const poster = this.poster(manager, roster);
|
|
46509
|
+
const actions = new Actions({
|
|
46510
|
+
bot: (agentId) => roster.clientOf(agentId),
|
|
46511
|
+
manager,
|
|
46512
|
+
agents: () => roster.all().map((m) => ({ agentId: m.agentId, agentName: m.agentName, username: m.username, botUserId: m.botUserId })),
|
|
46513
|
+
canTalk: async (userId, agentId) => !canTalk || canTalk(userId, agentId),
|
|
46514
|
+
origin: (agentId) => bridge.activeThread(agentId),
|
|
46515
|
+
task: async (taskId) => await ctx.issues.get(taskId, companyId),
|
|
46516
|
+
askApproval: async (req) => {
|
|
46517
|
+
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 });
|
|
46518
|
+
const { message: message2, props } = requestCard(req, actionUrl, key);
|
|
46519
|
+
const targets = [req.origin, approvalsChannel ? await poster.channel(approvalsChannel).then((id) => id && id !== req.origin?.channelId ? { channelId: id, rootId: "" } : null) : null];
|
|
46520
|
+
const postIds = [];
|
|
46521
|
+
for (const where of targets) if (where) postIds.push(await poster.post(req.agentId, where, message2, props));
|
|
46522
|
+
await store.set(`reqcard:${req.id}`, postIds);
|
|
46523
|
+
},
|
|
46524
|
+
store,
|
|
46525
|
+
newId: () => randomUUID2()
|
|
46526
|
+
});
|
|
46527
|
+
const work = new WorkLinks({
|
|
46528
|
+
store,
|
|
46529
|
+
post: async (agentId, channelId, rootId, text2) => {
|
|
46530
|
+
const as = roster.clientOf(agentId) ?? manager;
|
|
46531
|
+
await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: text2, props: FROM_PAPERCLIP });
|
|
46532
|
+
},
|
|
46533
|
+
hasBot: (agentId) => Boolean(roster.clientOf(agentId)),
|
|
46534
|
+
agentName: (agentId) => agentName(agentId),
|
|
46535
|
+
archive: async (channelId) => {
|
|
46536
|
+
await manager.request("DELETE", `/channels/${channelId}`);
|
|
46537
|
+
},
|
|
46538
|
+
channelName: async (channelId) => (await manager.request("GET", `/channels/${channelId}`)).display_name,
|
|
46539
|
+
task: async (issueId) => await ctx.issues.get(issueId, companyId),
|
|
46540
|
+
comments: async (issueId) => (await ctx.issues.listComments(issueId, companyId)).map((c) => ({ id: c.id, body: c.body, authorAgentId: c.authorAgentId }))
|
|
46541
|
+
});
|
|
46542
|
+
Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers });
|
|
45992
46543
|
this.status = `connecting to ${url2} as @${manager.botUsername}`;
|
|
45993
46544
|
detached(() => {
|
|
45994
46545
|
manager.connect(onPosted);
|
|
@@ -46011,7 +46562,28 @@ var Tenant = class {
|
|
|
46011
46562
|
this.timers = [];
|
|
46012
46563
|
this.bots?.close();
|
|
46013
46564
|
this.manager?.close();
|
|
46014
|
-
Object.assign(this, { manager: null, bots: null, bridge: null, approvals: null, cards: null });
|
|
46565
|
+
Object.assign(this, { manager: null, bots: null, bridge: null, approvals: null, cards: null, actions: null, work: null });
|
|
46566
|
+
}
|
|
46567
|
+
/** Approve / Reject on an agent's ask-first request (a broadcast, a public channel, an invite). */
|
|
46568
|
+
async decideRequest(req) {
|
|
46569
|
+
const { requestId, action, sig: sig2 } = req.context ?? {};
|
|
46570
|
+
if (!this.actions || !this.manager || !requestId || action !== "approve" && action !== "reject" || !signed(this.key, `req:${requestId}`, action, sig2)) return "This button is not valid.";
|
|
46571
|
+
const email3 = req.user_id ? (await this.manager.request("GET", `/users/${req.user_id}`).catch(() => ({ email: "" }))).email?.toLowerCase() : "";
|
|
46572
|
+
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.";
|
|
46573
|
+
const decided = await this.actions.decide(requestId, action === "approve", req.user_name ? `@${req.user_name}` : email3);
|
|
46574
|
+
if (!decided) return "That request is gone.";
|
|
46575
|
+
const { message: message2, props } = requestCard(decided, "", this.key);
|
|
46576
|
+
const store = this.store();
|
|
46577
|
+
for (const postId of await store.get(`reqcard:${requestId}`) ?? []) {
|
|
46578
|
+
await this.manager.request("PUT", `/posts/${postId}/patch`, { message: message2, props }).catch(() => {
|
|
46579
|
+
});
|
|
46580
|
+
}
|
|
46581
|
+
return null;
|
|
46582
|
+
}
|
|
46583
|
+
/** An agent's call to one of the plugin's `/mm/...` routes. */
|
|
46584
|
+
async api(agentId, routeKey, params, input2) {
|
|
46585
|
+
if (!this.actions) return { status: 503, body: { error: `Mattermost is not connected for this company (${this.status})` } };
|
|
46586
|
+
return this.actions.handle(agentId, routeKey, params, input2);
|
|
46015
46587
|
}
|
|
46016
46588
|
async notify(req, notice) {
|
|
46017
46589
|
if (notice && req.user_id && req.channel_id) {
|
|
@@ -46027,6 +46599,10 @@ var Tenant = class {
|
|
|
46027
46599
|
if (this.cards) await this.notify(req, await this.cards.click(req).catch(failed));
|
|
46028
46600
|
return;
|
|
46029
46601
|
}
|
|
46602
|
+
if (kind === "request") {
|
|
46603
|
+
await this.notify(req, await this.decideRequest(req).catch(failed));
|
|
46604
|
+
return;
|
|
46605
|
+
}
|
|
46030
46606
|
if (kind === "choice") {
|
|
46031
46607
|
const choice = req.context;
|
|
46032
46608
|
if (!this.manager || !this.bridge || !req.user_id || !req.post_id || !verifyChoice(this.key, choice)) return;
|
|
@@ -46044,6 +46620,28 @@ var Tenant = class {
|
|
|
46044
46620
|
if (this.approvals) await this.notify(req, await this.approvals.click(req).catch(failed));
|
|
46045
46621
|
}
|
|
46046
46622
|
};
|
|
46623
|
+
function requestCard(r, actionUrl, key) {
|
|
46624
|
+
const pending2 = r.status === "pending";
|
|
46625
|
+
const button = (id, name, style) => ({
|
|
46626
|
+
id,
|
|
46627
|
+
name,
|
|
46628
|
+
style,
|
|
46629
|
+
type: "button",
|
|
46630
|
+
integration: { url: actionUrl, context: { kind: "request", requestId: r.id, action: id, sig: sign(key, `req:${r.id}`, id) } }
|
|
46631
|
+
});
|
|
46632
|
+
const outcome = r.status === "approved" ? ":white_check_mark: Approved" : r.status === "rejected" ? ":x: Rejected" : ":warning: Approved, but it failed";
|
|
46633
|
+
return {
|
|
46634
|
+
message: `**${r.agentName}** asks to ${r.summary}`,
|
|
46635
|
+
props: {
|
|
46636
|
+
attachments: [{
|
|
46637
|
+
color: pending2 ? "#f2a900" : r.status === "approved" ? "#3db887" : "#d24b4e",
|
|
46638
|
+
text: `Needs approval because ${r.why}.`,
|
|
46639
|
+
footer: pending2 ? "Approvers decide here" : `${outcome} by ${r.decidedBy}`,
|
|
46640
|
+
actions: pending2 ? [button("approve", "Approve", "primary"), button("reject", "Reject", "danger")] : []
|
|
46641
|
+
}]
|
|
46642
|
+
}
|
|
46643
|
+
};
|
|
46644
|
+
}
|
|
46047
46645
|
function directoryOf(manager) {
|
|
46048
46646
|
return {
|
|
46049
46647
|
userName: (id) => manager.userName(id),
|
|
@@ -46096,8 +46694,8 @@ var plugin = definePlugin({
|
|
|
46096
46694
|
const accounts = {};
|
|
46097
46695
|
if (manager) {
|
|
46098
46696
|
for (const team of await manager.request("GET", "/users/me/teams").catch(() => [])) {
|
|
46099
|
-
const
|
|
46100
|
-
channels.push(...
|
|
46697
|
+
const list2 = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
|
|
46698
|
+
channels.push(...list2.filter((ch) => ch.type === "O").map((ch) => ({ name: ch.name, displayName: ch.display_name })));
|
|
46101
46699
|
}
|
|
46102
46700
|
channels = channels.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
46103
46701
|
for (const email3 of emails) {
|
|
@@ -46111,6 +46709,13 @@ var plugin = definePlugin({
|
|
|
46111
46709
|
ctx.events.on("approval.created", (e) => quietly("could not post approval", async () => {
|
|
46112
46710
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.created(e.entityId);
|
|
46113
46711
|
}));
|
|
46712
|
+
ctx.events.on("issue.comment.created", (e) => quietly("could not mirror a task comment", async () => {
|
|
46713
|
+
const commentId = e.payload?.commentId;
|
|
46714
|
+
if (e.entityId && commentId) await tenants.get(e.companyId)?.work?.comment(e.entityId, commentId);
|
|
46715
|
+
}));
|
|
46716
|
+
ctx.events.on("issue.updated", (e) => quietly("could not follow a task update", async () => {
|
|
46717
|
+
if (e.entityId) await tenants.get(e.companyId)?.work?.updated(e.entityId);
|
|
46718
|
+
}));
|
|
46114
46719
|
ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
|
|
46115
46720
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.decided(e.entityId);
|
|
46116
46721
|
}));
|
|
@@ -46138,16 +46743,27 @@ var plugin = definePlugin({
|
|
|
46138
46743
|
const tenant = typeof companyId === "string" ? tenants.get(companyId) : tenants.size === 1 ? [...tenants.values()][0] : void 0;
|
|
46139
46744
|
await tenant?.click(req);
|
|
46140
46745
|
},
|
|
46746
|
+
async onApiRequest(input2) {
|
|
46747
|
+
const agentId = input2.actor.agentId;
|
|
46748
|
+
if (input2.actor.actorType !== "agent" || !agentId) return { status: 403, body: { error: "Only agents call these routes." } };
|
|
46749
|
+
if (input2.routeKey === "help") return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body: HELP };
|
|
46750
|
+
const tenant = tenants.get(input2.companyId);
|
|
46751
|
+
if (!tenant) return { status: 503, body: { error: "Mattermost is not set up for your company." } };
|
|
46752
|
+
const query = Object.fromEntries(Object.entries(input2.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
|
|
46753
|
+
const body = input2.body && typeof input2.body === "object" && !Array.isArray(input2.body) ? input2.body : {};
|
|
46754
|
+
const res = await tenant.api(agentId, input2.routeKey, input2.params, { ...query, ...body });
|
|
46755
|
+
return { status: res.status, body: res.body };
|
|
46756
|
+
},
|
|
46141
46757
|
async onHealth() {
|
|
46142
46758
|
if (!tenants.size) return { status: "error", message: "not configured: set up Mattermost in this plugin's settings for a company" };
|
|
46143
|
-
let
|
|
46759
|
+
let ok2 = true;
|
|
46144
46760
|
const parts = [...tenants.values()].map((t) => {
|
|
46145
46761
|
const live = t.bots?.connected ?? 0;
|
|
46146
46762
|
const total = t.bots?.size ?? 0;
|
|
46147
|
-
if (!t.manager?.connected || live !== total || !total)
|
|
46763
|
+
if (!t.manager?.connected || live !== total || !total) ok2 = false;
|
|
46148
46764
|
return `${t.status}; ${live}/${total} agent bots connected`;
|
|
46149
46765
|
});
|
|
46150
|
-
return { status:
|
|
46766
|
+
return { status: ok2 ? "ok" : "degraded", message: parts.join(" | ") + (versionNote ? ` | Note: ${versionNote}` : "") };
|
|
46151
46767
|
},
|
|
46152
46768
|
async onShutdown() {
|
|
46153
46769
|
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.13.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",
|