paperclip-plugin-mattermost 0.12.0 → 0.14.0

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