paperclip-plugin-mattermost 0.12.0 → 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 +5 -1
- package/dist/manifest.js +32 -2
- package/dist/ui/index.js +11 -0
- package/dist/worker.js +720 -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. `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
|
|
@@ -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.13.0 -m v0.13.0 && git push origin main v0.13.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,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,7 +44992,499 @@ 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. 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
|
+
|
|
44995
45486
|
// src/bridge.ts
|
|
45487
|
+
var DEFAULT_TURN_LIMIT = 10;
|
|
44996
45488
|
var TYPING_EVERY_MS = 3e3;
|
|
44997
45489
|
var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
|
|
44998
45490
|
var MAX_POST = 15e3;
|
|
@@ -45032,9 +45524,11 @@ var Bridge = class {
|
|
|
45032
45524
|
files;
|
|
45033
45525
|
choiceButtons;
|
|
45034
45526
|
canTalk;
|
|
45527
|
+
turnLimit;
|
|
45035
45528
|
constructor(roster, directory, agents, store, log = () => {
|
|
45036
|
-
}, files = null, choiceButtons = null, canTalk = null) {
|
|
45529
|
+
}, files = null, choiceButtons = null, canTalk = null, turnLimit = DEFAULT_TURN_LIMIT) {
|
|
45037
45530
|
this.files = files;
|
|
45531
|
+
this.turnLimit = turnLimit;
|
|
45038
45532
|
this.canTalk = canTalk;
|
|
45039
45533
|
this.choiceButtons = choiceButtons;
|
|
45040
45534
|
this.roster = roster;
|
|
@@ -45064,6 +45558,42 @@ var Bridge = class {
|
|
|
45064
45558
|
activeThread(agentId) {
|
|
45065
45559
|
return this.active.get(agentId) ?? null;
|
|
45066
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
|
+
}
|
|
45067
45597
|
target(evt, thread) {
|
|
45068
45598
|
const roster = this.roster();
|
|
45069
45599
|
if (evt.channelType === "D") {
|
|
@@ -45091,15 +45621,21 @@ var Bridge = class {
|
|
|
45091
45621
|
async process(evt, rootId) {
|
|
45092
45622
|
const { post } = evt;
|
|
45093
45623
|
const roster = this.roster();
|
|
45094
|
-
|
|
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;
|
|
45095
45627
|
const threadKey = `thread:${rootId}`;
|
|
45096
45628
|
const thread = await this.store.get(threadKey);
|
|
45097
|
-
const member = this.target(evt, thread);
|
|
45098
|
-
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;
|
|
45099
45635
|
const text2 = stripMention(post.message, member.username);
|
|
45100
45636
|
const fileIds = this.files ? post.file_ids ?? [] : [];
|
|
45101
45637
|
if (!text2 && !fileIds.length) return;
|
|
45102
|
-
if (this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
|
|
45638
|
+
if (!fromAgent && this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
|
|
45103
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(() => {
|
|
45104
45640
|
});
|
|
45105
45641
|
return;
|
|
@@ -45130,7 +45666,8 @@ var Bridge = class {
|
|
|
45130
45666
|
const { sender, where, history, text: text2, fileIds } = m;
|
|
45131
45667
|
const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
|
|
45132
45668
|
const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
|
|
45133
|
-
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 ? `
|
|
45134
45671
|
|
|
45135
45672
|
Earlier in this thread (oldest first):
|
|
45136
45673
|
${history}
|
|
@@ -45512,6 +46049,9 @@ var AgentBots = class {
|
|
|
45512
46049
|
isBot: (id) => all.some((m) => m.botUserId === id)
|
|
45513
46050
|
};
|
|
45514
46051
|
}
|
|
46052
|
+
all() {
|
|
46053
|
+
return [...this.members.values()];
|
|
46054
|
+
}
|
|
45515
46055
|
/** The Mattermost client of an agent's bot, for posts beyond chat replies. */
|
|
45516
46056
|
clientOf(agentId) {
|
|
45517
46057
|
return this.members.get(agentId)?.client;
|
|
@@ -45654,10 +46194,11 @@ var AgentBots = class {
|
|
|
45654
46194
|
};
|
|
45655
46195
|
|
|
45656
46196
|
// src/manifest.ts
|
|
46197
|
+
var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth: "agent", capability: "api.routes.register" });
|
|
45657
46198
|
var manifest = {
|
|
45658
46199
|
id: "mattermost-bridge",
|
|
45659
46200
|
apiVersion: 1,
|
|
45660
|
-
version: "0.
|
|
46201
|
+
version: "0.13.0",
|
|
45661
46202
|
displayName: "Mattermost",
|
|
45662
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.",
|
|
45663
46204
|
author: "Haseeb Azhar",
|
|
@@ -45681,11 +46222,32 @@ var manifest = {
|
|
|
45681
46222
|
"issue.interactions.read",
|
|
45682
46223
|
"issue.interactions.respond",
|
|
45683
46224
|
"issue.comments.create",
|
|
45684
|
-
"issue.comments.create_human_attributed"
|
|
46225
|
+
"issue.comments.create_human_attributed",
|
|
46226
|
+
"issue.comments.read",
|
|
46227
|
+
"api.routes.register"
|
|
45685
46228
|
],
|
|
45686
46229
|
entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
|
|
45687
46230
|
// Replaces the generated form: approvers picked from members, URL detected and checked.
|
|
45688
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
|
+
],
|
|
45689
46251
|
webhooks: [
|
|
45690
46252
|
{ endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
|
|
45691
46253
|
],
|
|
@@ -45735,6 +46297,14 @@ var manifest = {
|
|
|
45735
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.",
|
|
45736
46298
|
items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
|
|
45737
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
|
+
},
|
|
45738
46308
|
approvers: {
|
|
45739
46309
|
type: "array",
|
|
45740
46310
|
title: "Approvers",
|
|
@@ -45776,7 +46346,10 @@ var Tenant = class {
|
|
|
45776
46346
|
bridge = null;
|
|
45777
46347
|
approvals = null;
|
|
45778
46348
|
cards = null;
|
|
46349
|
+
actions = null;
|
|
46350
|
+
work = null;
|
|
45779
46351
|
key = "";
|
|
46352
|
+
approvers = /* @__PURE__ */ new Map();
|
|
45780
46353
|
timers = [];
|
|
45781
46354
|
agents() {
|
|
45782
46355
|
const companyId = this.companyId;
|
|
@@ -45857,8 +46430,8 @@ var Tenant = class {
|
|
|
45857
46430
|
}
|
|
45858
46431
|
}
|
|
45859
46432
|
for (const team of teams) {
|
|
45860
|
-
const
|
|
45861
|
-
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());
|
|
45862
46435
|
if (hit) return hit.id;
|
|
45863
46436
|
}
|
|
45864
46437
|
return null;
|
|
@@ -45882,6 +46455,7 @@ var Tenant = class {
|
|
|
45882
46455
|
const paperclipUrl = typeof config2.paperclipUrl === "string" ? config2.paperclipUrl.trim().replace(/\/+$/, "") : "";
|
|
45883
46456
|
const actionUrl = `${paperclipUrl}/api/plugins/${PLUGIN_KEY}/webhooks/actions`;
|
|
45884
46457
|
const key = createHmac2("sha256", token).update("mattermost-approval-buttons").digest("hex");
|
|
46458
|
+
const canTalk = accessCheck(manager, config2);
|
|
45885
46459
|
const bridge = new Bridge(
|
|
45886
46460
|
() => roster.roster(),
|
|
45887
46461
|
directoryOf(manager),
|
|
@@ -45890,7 +46464,8 @@ var Tenant = class {
|
|
|
45890
46464
|
warn,
|
|
45891
46465
|
diskFiles(),
|
|
45892
46466
|
paperclipUrl ? (where, choices) => this.tag(choiceProps(key, actionUrl, where, choices)) : null,
|
|
45893
|
-
|
|
46467
|
+
canTalk,
|
|
46468
|
+
Number(config2.agentTurnLimit) > 0 ? Number(config2.agentTurnLimit) : DEFAULT_TURN_LIMIT
|
|
45894
46469
|
);
|
|
45895
46470
|
onPosted = (evt) => {
|
|
45896
46471
|
void bridge.handle(evt);
|
|
@@ -45930,7 +46505,41 @@ var Tenant = class {
|
|
|
45930
46505
|
channel: approvalsChannel,
|
|
45931
46506
|
approvers
|
|
45932
46507
|
}) : null;
|
|
45933
|
-
|
|
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 });
|
|
45934
46543
|
this.status = `connecting to ${url2} as @${manager.botUsername}`;
|
|
45935
46544
|
detached(() => {
|
|
45936
46545
|
manager.connect(onPosted);
|
|
@@ -45953,7 +46562,28 @@ var Tenant = class {
|
|
|
45953
46562
|
this.timers = [];
|
|
45954
46563
|
this.bots?.close();
|
|
45955
46564
|
this.manager?.close();
|
|
45956
|
-
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);
|
|
45957
46587
|
}
|
|
45958
46588
|
async notify(req, notice) {
|
|
45959
46589
|
if (notice && req.user_id && req.channel_id) {
|
|
@@ -45969,6 +46599,10 @@ var Tenant = class {
|
|
|
45969
46599
|
if (this.cards) await this.notify(req, await this.cards.click(req).catch(failed));
|
|
45970
46600
|
return;
|
|
45971
46601
|
}
|
|
46602
|
+
if (kind === "request") {
|
|
46603
|
+
await this.notify(req, await this.decideRequest(req).catch(failed));
|
|
46604
|
+
return;
|
|
46605
|
+
}
|
|
45972
46606
|
if (kind === "choice") {
|
|
45973
46607
|
const choice = req.context;
|
|
45974
46608
|
if (!this.manager || !this.bridge || !req.user_id || !req.post_id || !verifyChoice(this.key, choice)) return;
|
|
@@ -45986,6 +46620,28 @@ var Tenant = class {
|
|
|
45986
46620
|
if (this.approvals) await this.notify(req, await this.approvals.click(req).catch(failed));
|
|
45987
46621
|
}
|
|
45988
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
|
+
}
|
|
45989
46645
|
function directoryOf(manager) {
|
|
45990
46646
|
return {
|
|
45991
46647
|
userName: (id) => manager.userName(id),
|
|
@@ -46038,8 +46694,8 @@ var plugin = definePlugin({
|
|
|
46038
46694
|
const accounts = {};
|
|
46039
46695
|
if (manager) {
|
|
46040
46696
|
for (const team of await manager.request("GET", "/users/me/teams").catch(() => [])) {
|
|
46041
|
-
const
|
|
46042
|
-
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 })));
|
|
46043
46699
|
}
|
|
46044
46700
|
channels = channels.sort((a, b) => a.displayName.localeCompare(b.displayName));
|
|
46045
46701
|
for (const email3 of emails) {
|
|
@@ -46053,6 +46709,13 @@ var plugin = definePlugin({
|
|
|
46053
46709
|
ctx.events.on("approval.created", (e) => quietly("could not post approval", async () => {
|
|
46054
46710
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.created(e.entityId);
|
|
46055
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
|
+
}));
|
|
46056
46719
|
ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
|
|
46057
46720
|
if (e.entityId) await tenants.get(e.companyId)?.approvals?.decided(e.entityId);
|
|
46058
46721
|
}));
|
|
@@ -46080,16 +46743,27 @@ var plugin = definePlugin({
|
|
|
46080
46743
|
const tenant = typeof companyId === "string" ? tenants.get(companyId) : tenants.size === 1 ? [...tenants.values()][0] : void 0;
|
|
46081
46744
|
await tenant?.click(req);
|
|
46082
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
|
+
},
|
|
46083
46757
|
async onHealth() {
|
|
46084
46758
|
if (!tenants.size) return { status: "error", message: "not configured: set up Mattermost in this plugin's settings for a company" };
|
|
46085
|
-
let
|
|
46759
|
+
let ok2 = true;
|
|
46086
46760
|
const parts = [...tenants.values()].map((t) => {
|
|
46087
46761
|
const live = t.bots?.connected ?? 0;
|
|
46088
46762
|
const total = t.bots?.size ?? 0;
|
|
46089
|
-
if (!t.manager?.connected || live !== total || !total)
|
|
46763
|
+
if (!t.manager?.connected || live !== total || !total) ok2 = false;
|
|
46090
46764
|
return `${t.status}; ${live}/${total} agent bots connected`;
|
|
46091
46765
|
});
|
|
46092
|
-
return { status:
|
|
46766
|
+
return { status: ok2 ? "ok" : "degraded", message: parts.join(" | ") + (versionNote ? ` | Note: ${versionNote}` : "") };
|
|
46093
46767
|
},
|
|
46094
46768
|
async onShutdown() {
|
|
46095
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",
|