paperclip-plugin-mattermost 0.11.3 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,7 +5,7 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
5
5
  ## What it does
6
6
 
7
7
  - **A bot per agent.** Bots are created, renamed and switched off as agents change (every 5 minutes). Choose all agents, or only the ones you pick.
8
- - **You see it working.** The agent's bot reacts 👀 to your message straight away. Its reply starts as _thinking…_ and lists the steps it runs, then the answer replaces the list.
8
+ - **You see it working.** The agent's bot reacts 👀 to your message straight away, and shows Mattermost's "is typing…" indicator until its answer arrives.
9
9
  - **Files both ways.** Attach files to a message and the agent can read them. Files the agent saves are attached to its reply.
10
10
  - **Choices as buttons.** An agent can offer options (`choices: A | B | C` on its last line), which appear as buttons. A click is sent back as your answer.
11
11
  - **Approvals with buttons.** Every Paperclip approval is posted in an approvals channel with Approve / Reject buttons, including hires, budget overrides, board approvals and high-risk tool calls. An approval raised during a conversation also appears in that thread.
@@ -13,6 +13,10 @@ Your [Paperclip](https://github.com/paperclipai/paperclip) agents as Mattermost
13
13
  - **Confirmations and suggested tasks** get Accept / Reject buttons.
14
14
  - **Single-choice questions** get a button per option. Your pick is posted on the task as a comment from you, which wakes the agent.
15
15
  - **Access control.** Choose who may talk to agents: everyone, or only the people you pick. Each person can be limited to some agents, and people outside Paperclip can be added by email.
16
+ - **Agents work in Mattermost themselves.** Through plugin API routes that only agents can call, each agent reads the channels its bot is in, posts, reacts, searches, and DMs the people allowed to talk to it, always as its own bot. `GET /api/plugins/mattermost-bridge/api/mm/help` tells an agent how.
17
+ - **Agents work with agents.** An agent mentioning another agent starts it, and it answers in the thread. After a set number of agent-to-agent messages in a thread (10 by default, set in settings), they pause until a person replies.
18
+ - **Work channels.** For an investigation, incident or project, an agent opens a private channel (`inc-checkout-5xx`), pulls in the people and agents it needs, and ties it to a Paperclip task. Task comments and status changes show up in the channel. When the task is done or cancelled, the channel is archived and the outcome is posted back where the work was asked for.
19
+ - **Ask first.** Some actions wait for an approver to click Approve: `@channel`, `@all` and `@here`, public channels, and adding people to a channel the agent didn't create. Agents can't change or archive channels they didn't create, remove anyone, or touch team and server settings.
16
20
  - **One install, many companies.** Each Paperclip company connects its own Mattermost, with its own settings, bots and state.
17
21
 
18
22
  ## Requirements
@@ -60,7 +64,6 @@ Or install it from **Settings → Plugins** in Paperclip.
60
64
  - **Multi-choice questions** and free-text "Other" answers are answered in Paperclip; the card links there.
61
65
  - **Mattermost can't hide bots** from particular people. Someone without access can still DM an agent bot; they get a private note and the agent doesn't run.
62
66
  - **Agents with a custom uploaded portrait** keep the default bot picture, because those images need a Paperclip login to download.
63
- - **Live progress** shows steps for agents run through ACPX (Claude Code, and others Paperclip runs that way). Other adapters show only _thinking…_.
64
67
 
65
68
  ## Development
66
69
 
@@ -79,7 +82,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
79
82
  Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
80
83
 
81
84
  ```bash
82
- git tag v0.12.0 && git push origin main v0.12.0
85
+ git tag -a v0.13.0 -m v0.13.0 && git push origin main v0.13.0
83
86
  ```
84
87
 
85
88
  GitHub Actions builds, tests and publishes to npm through npm trusted publishing, so no npm token is involved.
package/dist/manifest.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // src/manifest.ts
2
+ var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent", capability: "api.routes.register" });
2
3
  var manifest = {
3
4
  id: "mattermost-bridge",
4
5
  apiVersion: 1,
5
- version: "0.11.3",
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 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,57 +44992,501 @@ function chosenProps(choice, who) {
44992
44992
  return { attachments: [{ text: `${who} chose **${choice}**`, actions: [] }] };
44993
44993
  }
44994
44994
 
44995
- // src/progress.ts
44996
- var THINKING = "_thinking\u2026_";
44997
- var MAX_STEPS = 6;
44998
- var MAX_LABEL2 = 100;
44999
- var Progress = class {
45000
- buffer = "";
45001
- steps = /* @__PURE__ */ new Map();
45002
- /** Feeds one stdout chunk. Returns true when what `render` shows changed. */
45003
- feed(chunk) {
45004
- this.buffer += chunk;
45005
- const lines = this.buffer.split("\n");
45006
- this.buffer = lines.pop() ?? "";
45007
- let changed = false;
45008
- for (const line of lines) {
45009
- if (!line.startsWith("{")) continue;
45010
- let e;
44995
+ // src/policy.ts
44996
+ var ALLOW = { ok: true };
44997
+ var ask = (why) => ({ ok: false, askFirst: true, why });
44998
+ var refuse = (why) => ({ ok: false, askFirst: false, why });
44999
+ function broadcastMention(text2) {
45000
+ const m = text2.match(/(?:^|[^\w@])@(channel|all|here)\b/i);
45001
+ return m ? `@${m[1].toLowerCase()}` : null;
45002
+ }
45003
+ function canPost(text2) {
45004
+ const b = broadcastMention(text2);
45005
+ return b ? ask(`${b} notifies everyone in the channel`) : ALLOW;
45006
+ }
45007
+ function canCreateChannel(isPrivate) {
45008
+ return isPrivate ? ALLOW : ask("a public channel is visible to the whole team");
45009
+ }
45010
+ function canManage(createdByThisAgent, action) {
45011
+ if (createdByThisAgent) return ALLOW;
45012
+ if (action === "invite") return ask("the agent did not create this channel");
45013
+ return refuse("only the agent that created a channel may change or archive it");
45014
+ }
45015
+ function canDm(allowedToTalk) {
45016
+ return allowedToTalk ? ALLOW : refuse("that person is not allowed to talk to this agent");
45017
+ }
45018
+ function channelSlug(name) {
45019
+ return name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "work";
45020
+ }
45021
+
45022
+ // src/actions.ts
45023
+ var ok = (body) => ({ status: 200, body });
45024
+ var bad = (status, error62) => ({ status, body: { error: error62 } });
45025
+ var str3 = (v) => typeof v === "string" ? v.trim() : "";
45026
+ var list = (v) => Array.isArray(v) ? v.map(str3).filter(Boolean) : typeof v === "string" && v.trim() ? v.split(",").map((s) => s.trim()).filter(Boolean) : [];
45027
+ var ID = /^[a-z0-9]{26}$/;
45028
+ var Actions = class {
45029
+ d;
45030
+ names = /* @__PURE__ */ new Map();
45031
+ constructor(deps) {
45032
+ this.d = deps;
45033
+ }
45034
+ /** One route call. `params` are the route's path parameters, `input` its query or body. */
45035
+ async handle(agentId, routeKey, params, input2) {
45036
+ const bot = this.d.bot(agentId);
45037
+ if (!bot) return bad(409, "You have no Mattermost bot in this company yet (your agent may be switched off in the plugin's settings).");
45038
+ try {
45039
+ switch (routeKey) {
45040
+ case "channels":
45041
+ return ok(await this.myChannels(bot));
45042
+ case "channel-posts":
45043
+ return ok(await this.posts(bot, params.channel, input2));
45044
+ case "thread":
45045
+ return ok(await this.thread(bot, params.postId));
45046
+ case "channel-members":
45047
+ return ok(await this.members(bot, params.channel));
45048
+ case "search":
45049
+ return ok(await this.search(bot, str3(input2.q)));
45050
+ case "people":
45051
+ return ok(await this.people(str3(input2.q)));
45052
+ case "post":
45053
+ return await this.post(agentId, bot, input2);
45054
+ case "react":
45055
+ return await this.react(bot, input2);
45056
+ case "dm":
45057
+ return await this.dm(agentId, bot, input2);
45058
+ case "create-channel":
45059
+ return await this.createChannel(agentId, bot, input2);
45060
+ case "invite":
45061
+ return await this.invite(agentId, bot, params.channel, input2);
45062
+ case "update-channel":
45063
+ return await this.updateChannel(agentId, bot, params.channel, input2);
45064
+ case "archive":
45065
+ return await this.archive(agentId, bot, params.channel);
45066
+ case "request":
45067
+ return ok(await this.d.store.get(`req:${params.id}`) ?? { error: "no such request" });
45068
+ default:
45069
+ return bad(404, `unknown action ${routeKey}`);
45070
+ }
45071
+ } catch (err) {
45072
+ const status = err?.status;
45073
+ const message2 = String(err?.message ?? err).replace(/^Mattermost \w+ \S+: \d+ /, "");
45074
+ return bad(status && status >= 400 && status < 500 ? status : 502, `Mattermost refused or failed: ${message2.slice(0, 300)}`);
45075
+ }
45076
+ }
45077
+ // ---- reading -------------------------------------------------------------------
45078
+ async teamOf(bot) {
45079
+ const teams = await bot.request("GET", "/users/me/teams");
45080
+ if (!teams.length) throw Object.assign(new Error("your bot is in no team"), { status: 409 });
45081
+ return teams[0].id;
45082
+ }
45083
+ /** A channel by id, URL name ("decisions"), display name ("Decisions") or "~decisions". */
45084
+ async channel(bot, ref) {
45085
+ const want = str3(ref).replace(/^[~#]/, "");
45086
+ if (ID.test(want)) return bot.request("GET", `/channels/${want}`);
45087
+ for (const team of await bot.request("GET", "/users/me/teams")) {
45011
45088
  try {
45012
- e = JSON.parse(line);
45089
+ return await bot.request("GET", `/teams/${team.id}/channels/name/${encodeURIComponent(channelSlug(want))}`);
45013
45090
  } catch {
45091
+ }
45092
+ const mine = await bot.request("GET", `/users/me/teams/${team.id}/channels`).catch(() => []);
45093
+ const hit = mine.find((c) => c.display_name.toLowerCase() === want.toLowerCase());
45094
+ if (hit) return hit;
45095
+ }
45096
+ throw Object.assign(new Error(`no channel "${ref}" that you can see; you can only use channels you are a member of`), { status: 404 });
45097
+ }
45098
+ async userName(id) {
45099
+ if (!this.names.has(id)) {
45100
+ const u = await this.d.manager.request("GET", `/users/${id}`).catch(() => ({ username: id }));
45101
+ this.names.set(id, u.username);
45102
+ }
45103
+ return this.names.get(id);
45104
+ }
45105
+ async shape(p) {
45106
+ return { id: p.id, from: `@${await this.userName(p.user_id)}`, at: new Date(p.create_at).toISOString(), threadId: p.root_id || null, text: p.message };
45107
+ }
45108
+ async ordered(res) {
45109
+ const posts = res.order.map((id) => res.posts[id]).filter(Boolean).sort((a, b) => a.create_at - b.create_at);
45110
+ return Promise.all(posts.map((p) => this.shape(p)));
45111
+ }
45112
+ async myChannels(bot) {
45113
+ const team = await this.teamOf(bot);
45114
+ const created = await this.created();
45115
+ const channels = await bot.request("GET", `/users/me/teams/${team}/channels`);
45116
+ return channels.filter((c) => c.type === "O" || c.type === "P").map((c) => ({
45117
+ id: c.id,
45118
+ name: c.name,
45119
+ displayName: c.display_name,
45120
+ private: c.type === "P",
45121
+ purpose: c.purpose || "",
45122
+ createdByYou: created[c.id] === bot.botUserId
45123
+ }));
45124
+ }
45125
+ async posts(bot, ref, input2) {
45126
+ const ch = await this.channel(bot, ref);
45127
+ const limit = Math.min(Math.max(Number(input2.limit) || 30, 1), 100);
45128
+ return { channel: ch.name, posts: await this.ordered(await bot.request(`GET`, `/channels/${ch.id}/posts?per_page=${limit}`)) };
45129
+ }
45130
+ async thread(bot, postId) {
45131
+ return { posts: await this.ordered(await bot.request("GET", `/posts/${postId}/thread`)) };
45132
+ }
45133
+ async members(bot, ref) {
45134
+ const ch = await this.channel(bot, ref);
45135
+ const ms = await bot.request("GET", `/channels/${ch.id}/members?per_page=200`);
45136
+ const agents = new Map(this.d.agents().map((a) => [a.botUserId, a]));
45137
+ return Promise.all(ms.map(async (m) => {
45138
+ const a = agents.get(m.user_id);
45139
+ return a ? { username: a.username, agent: a.agentName } : { username: await this.userName(m.user_id) };
45140
+ }));
45141
+ }
45142
+ async search(bot, q) {
45143
+ if (!q) throw Object.assign(new Error("say what to search for (q)"), { status: 400 });
45144
+ const team = await this.teamOf(bot);
45145
+ return { posts: (await this.ordered(await bot.request("POST", `/teams/${team}/posts/search`, { terms: q, is_or_search: false }))).slice(-30) };
45146
+ }
45147
+ async people(q) {
45148
+ const users = await this.d.manager.request(
45149
+ "POST",
45150
+ "/users/search",
45151
+ { term: q, allow_inactive: false, limit: 25 }
45152
+ );
45153
+ const agents = new Map(this.d.agents().map((a) => [a.botUserId, a]));
45154
+ return users.filter((u) => !u.delete_at && (!u.is_bot || agents.has(u.id))).map((u) => agents.has(u.id) ? { username: u.username, agent: agents.get(u.id).agentName } : { username: u.username, name: [u.first_name, u.last_name].filter(Boolean).join(" ") || u.username });
45155
+ }
45156
+ // ---- talking ---------------------------------------------------------------------
45157
+ async gate(agentId, verdict, kind, summary, args) {
45158
+ if (verdict.ok) return null;
45159
+ if (!verdict.askFirst) return bad(403, `Not allowed: ${verdict.why}.`);
45160
+ const request = {
45161
+ id: this.d.newId(),
45162
+ agentId,
45163
+ agentName: this.agentName(agentId),
45164
+ kind,
45165
+ summary,
45166
+ why: verdict.why,
45167
+ args,
45168
+ origin: this.d.origin(agentId),
45169
+ status: "pending"
45170
+ };
45171
+ await this.d.store.set(`req:${request.id}`, request);
45172
+ await this.d.askApproval(request);
45173
+ return { status: 202, body: {
45174
+ status: "waiting_for_approval",
45175
+ requestId: request.id,
45176
+ why: verdict.why,
45177
+ note: "A person has been asked to approve this. Tell whoever you are working with, and carry on; if approved it runs as you, and the result is posted where you asked."
45178
+ } };
45179
+ }
45180
+ agentName(agentId) {
45181
+ return this.d.agents().find((a) => a.agentId === agentId)?.agentName ?? "An agent";
45182
+ }
45183
+ async post(agentId, bot, input2) {
45184
+ const text2 = str3(input2.text);
45185
+ if (!text2) return bad(400, "text is empty");
45186
+ const ch = await this.channel(bot, str3(input2.channel));
45187
+ const threadId = str3(input2.threadId);
45188
+ const waiting = await this.gate(agentId, canPost(text2), "post", `post in ~${ch.name}: "${text2.slice(0, 200)}"`, { channelId: ch.id, threadId, text: text2 });
45189
+ if (waiting) return waiting;
45190
+ return ok(await this.doPost(bot, ch.id, threadId, text2));
45191
+ }
45192
+ async doPost(bot, channelId, threadId, text2) {
45193
+ const p = await bot.request("POST", "/posts", { channel_id: channelId, root_id: threadId, message: text2 });
45194
+ return { posted: p.id, threadId: p.root_id || p.id };
45195
+ }
45196
+ async react(bot, input2) {
45197
+ const emoji3 = str3(input2.emoji).replace(/^:|:$/g, "");
45198
+ if (!str3(input2.postId) || !emoji3) return bad(400, "postId and emoji are required");
45199
+ await bot.request("POST", "/reactions", { user_id: bot.botUserId, post_id: str3(input2.postId), emoji_name: emoji3 });
45200
+ return ok({ reacted: emoji3 });
45201
+ }
45202
+ async dm(agentId, bot, input2) {
45203
+ const text2 = str3(input2.text);
45204
+ const username = str3(input2.person).replace(/^@/, "");
45205
+ if (!text2 || !username) return bad(400, "person and text are required");
45206
+ const user2 = await this.d.manager.request("GET", `/users/username/${encodeURIComponent(username)}`);
45207
+ const verdict = canDm(await this.d.canTalk(user2.id, agentId));
45208
+ if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
45209
+ const channel2 = await bot.request("POST", "/channels/direct", [bot.botUserId, user2.id]);
45210
+ return ok(await this.doPost(bot, channel2.id, "", text2));
45211
+ }
45212
+ // ---- channels ----------------------------------------------------------------------
45213
+ async created() {
45214
+ return await this.d.store.get("channels-created") ?? {};
45215
+ }
45216
+ async createdBy(channelId) {
45217
+ return (await this.created())[channelId];
45218
+ }
45219
+ async resolvePeople(names) {
45220
+ const agents = this.d.agents();
45221
+ const ids = [];
45222
+ const unknown2 = [];
45223
+ for (const raw of names) {
45224
+ const n = raw.replace(/^@/, "").toLowerCase();
45225
+ const a = agents.find((x) => x.username === n || x.agentName.toLowerCase() === n);
45226
+ if (a) {
45227
+ ids.push(a.botUserId);
45014
45228
  continue;
45015
45229
  }
45016
- if (e.type !== "acpx.tool_call" || !e.toolCallId) continue;
45017
- const prev = this.steps.get(e.toolCallId);
45018
- const tool3 = prev?.tool ?? e.name ?? "tool";
45019
- const label2 = e.name && e.name !== tool3 ? e.name : prev?.label ?? tool3;
45020
- const status = e.status ?? prev?.status ?? "pending";
45021
- if (prev?.label !== label2 || prev?.status !== status) {
45022
- this.steps.set(e.toolCallId, { tool: tool3, label: label2, status });
45023
- changed = true;
45024
- }
45025
- }
45026
- return changed;
45027
- }
45028
- render() {
45029
- const head = THINKING;
45030
- const all = [...this.steps.values()];
45031
- if (!all.length) return head;
45032
- const shown = all.slice(-MAX_STEPS);
45033
- const lines = shown.map((s) => {
45034
- const mark = s.status === "completed" ? "\u2713" : s.status === "failed" ? "\u2717" : "\u2026";
45035
- const label2 = s.label.replace(/\s+/g, " ").trim();
45036
- return `- ${mark} \`${(label2.length > MAX_LABEL2 ? label2.slice(0, MAX_LABEL2) + "\u2026" : label2).replace(/`/g, "'")}\``;
45230
+ const u = await this.d.manager.request("GET", `/users/username/${encodeURIComponent(n)}`).catch(() => null);
45231
+ if (u) ids.push(u.id);
45232
+ else unknown2.push(raw);
45233
+ }
45234
+ return { ids, unknown: unknown2 };
45235
+ }
45236
+ async addMembers(channelId, ids) {
45237
+ for (const id of ids) await this.d.manager.request("POST", `/channels/${channelId}/members`, { user_id: id }).catch(() => {
45037
45238
  });
45038
- if (all.length > shown.length) lines.unshift(`- _${all.length - shown.length} earlier steps_`);
45039
- return `${head}
45040
- ${lines.join("\n")}`;
45239
+ }
45240
+ async createChannel(agentId, bot, input2) {
45241
+ const isPrivate = input2.private !== false;
45242
+ const display = str3(input2.name);
45243
+ if (!display) return bad(400, "name is required, e.g. 'inc checkout 5xx'");
45244
+ const taskId = str3(input2.taskId);
45245
+ if (taskId && !await this.d.task(taskId)) return bad(404, `no task ${taskId} in this company`);
45246
+ const args = { name: display, private: isPrivate, purpose: str3(input2.purpose), people: list(input2.people), agents: list(input2.agents), taskId };
45247
+ const waiting = await this.gate(agentId, canCreateChannel(isPrivate), "create-channel", `open the public channel "${display}"`, args);
45248
+ if (waiting) return waiting;
45249
+ return ok(await this.doCreateChannel(agentId, bot, args, this.d.origin(agentId)));
45250
+ }
45251
+ async doCreateChannel(agentId, bot, args, origin) {
45252
+ const team = await this.teamOf(bot);
45253
+ const display = str3(args.name);
45254
+ let ch = null;
45255
+ for (const suffix of ["", "-2", "-3", "-4", "-5"]) {
45256
+ try {
45257
+ ch = await bot.request("POST", "/channels", {
45258
+ team_id: team,
45259
+ name: channelSlug(display) + suffix,
45260
+ display_name: display.slice(0, 64),
45261
+ type: args.private === false ? "O" : "P",
45262
+ purpose: str3(args.purpose).slice(0, 250)
45263
+ });
45264
+ break;
45265
+ } catch (err) {
45266
+ if (err.status !== 400) throw err;
45267
+ }
45268
+ }
45269
+ if (!ch) throw Object.assign(new Error(`a channel named like "${display}" exists already; pick another name`), { status: 409 });
45270
+ const created = await this.created();
45271
+ created[ch.id] = bot.botUserId;
45272
+ await this.d.store.set("channels-created", created);
45273
+ const { ids, unknown: unknown2 } = await this.resolvePeople([...list(args.people), ...list(args.agents)]);
45274
+ await this.addMembers(ch.id, ids);
45275
+ let task2 = null;
45276
+ const taskId = str3(args.taskId);
45277
+ if (taskId) {
45278
+ const t = await this.d.task(taskId);
45279
+ if (t) {
45280
+ task2 = { issueId: t.id, identifier: t.identifier ?? null, title: t.title, agentId, origin };
45281
+ await this.d.store.set(`worklink:${ch.id}`, task2);
45282
+ await this.d.store.set(`taskchannel:${t.id}`, ch.id);
45283
+ }
45284
+ }
45285
+ return { channel: ch.name, channelId: ch.id, private: ch.type === "P", added: ids.length, unknownPeople: unknown2, linkedTask: task2?.identifier ?? task2?.issueId ?? null };
45286
+ }
45287
+ async invite(agentId, bot, ref, input2) {
45288
+ const ch = await this.channel(bot, ref);
45289
+ const names = [...list(input2.people), ...list(input2.agents)];
45290
+ if (!names.length) return bad(400, "people or agents to invite are required");
45291
+ const mine = await this.createdBy(ch.id) === bot.botUserId;
45292
+ const waiting = await this.gate(agentId, canManage(mine, "invite"), "invite", `add ${names.join(", ")} to ~${ch.name}`, { channelId: ch.id, names });
45293
+ if (waiting) return waiting;
45294
+ return ok(await this.doInvite(ch.id, names));
45295
+ }
45296
+ async doInvite(channelId, names) {
45297
+ const { ids, unknown: unknown2 } = await this.resolvePeople(names);
45298
+ await this.addMembers(channelId, ids);
45299
+ return { added: ids.length, unknownPeople: unknown2 };
45300
+ }
45301
+ async updateChannel(agentId, bot, ref, input2) {
45302
+ const ch = await this.channel(bot, ref);
45303
+ const verdict = canManage(await this.createdBy(ch.id) === bot.botUserId, "update");
45304
+ if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
45305
+ const patch = {};
45306
+ if (typeof input2.purpose === "string") patch.purpose = input2.purpose.slice(0, 250);
45307
+ if (typeof input2.header === "string") patch.header = input2.header.slice(0, 1024);
45308
+ if (typeof input2.name === "string" && input2.name.trim()) patch.display_name = input2.name.trim().slice(0, 64);
45309
+ if (!Object.keys(patch).length) return bad(400, "nothing to change: purpose, header or name");
45310
+ await this.d.manager.request("PUT", `/channels/${ch.id}/patch`, patch);
45311
+ return ok({ updated: Object.keys(patch) });
45312
+ }
45313
+ async archive(agentId, bot, ref) {
45314
+ const ch = await this.channel(bot, ref);
45315
+ const verdict = canManage(await this.createdBy(ch.id) === bot.botUserId, "archive");
45316
+ if (!verdict.ok) return bad(403, `Not allowed: ${verdict.why}.`);
45317
+ await this.d.manager.request("DELETE", `/channels/${ch.id}`);
45318
+ return ok({ archived: ch.name });
45319
+ }
45320
+ /** A person approved or rejected a request: run it as the agent, then report where it was asked. */
45321
+ async decide(requestId, approved, who) {
45322
+ const req = await this.d.store.get(`req:${requestId}`);
45323
+ if (!req || req.status !== "pending") return req;
45324
+ req.decidedBy = who;
45325
+ if (!approved) {
45326
+ req.status = "rejected";
45327
+ } else {
45328
+ const bot2 = this.d.bot(req.agentId);
45329
+ try {
45330
+ if (!bot2) throw new Error("the agent has no bot any more");
45331
+ const a = req.args;
45332
+ req.result = req.kind === "post" ? await this.doPost(bot2, str3(a.channelId), str3(a.threadId), str3(a.text)) : req.kind === "create-channel" ? await this.doCreateChannel(req.agentId, bot2, a, req.origin) : await this.doInvite(str3(a.channelId), list(a.names));
45333
+ req.status = "approved";
45334
+ } catch (err) {
45335
+ req.status = "failed";
45336
+ req.result = { error: String(err?.message ?? err).slice(0, 300) };
45337
+ }
45338
+ }
45339
+ await this.d.store.set(`req:${requestId}`, req);
45340
+ const bot = this.d.bot(req.agentId);
45341
+ if (bot && req.origin) {
45342
+ const line = req.status === "approved" ? `:white_check_mark: ${who} approved: ${req.summary}.` : req.status === "rejected" ? `:x: ${who} rejected: ${req.summary}.` : `:warning: Approved by ${who}, but it failed: ${req.result?.error}`;
45343
+ await bot.request("POST", "/posts", { channel_id: req.origin.channelId, root_id: req.origin.rootId, message: line }).catch(() => {
45344
+ });
45345
+ }
45346
+ return req;
45347
+ }
45348
+ };
45349
+
45350
+ // src/worklinks.ts
45351
+ var FROM_PAPERCLIP = { from_paperclip: "true" };
45352
+ var CLOSED = /* @__PURE__ */ new Set(["done", "cancelled"]);
45353
+ var STATUS_WORDS = {
45354
+ todo: "to do",
45355
+ in_progress: "in progress",
45356
+ in_review: "in review",
45357
+ blocked: "blocked",
45358
+ backlog: "in the backlog",
45359
+ done: "done",
45360
+ cancelled: "cancelled"
45361
+ };
45362
+ var WorkLinks = class {
45363
+ d;
45364
+ constructor(deps) {
45365
+ this.d = deps;
45366
+ }
45367
+ async linked(issueId) {
45368
+ const channelId = await this.d.store.get(`taskchannel:${issueId}`);
45369
+ const link = channelId ? await this.d.store.get(`worklink:${channelId}`) : null;
45370
+ return channelId && link && !link.closed ? { channelId, link } : null;
45371
+ }
45372
+ label(link) {
45373
+ return link.identifier ?? link.title;
45374
+ }
45375
+ /** A new comment on a task with a work channel. */
45376
+ async comment(issueId, commentId) {
45377
+ const hit = await this.linked(issueId);
45378
+ if (!hit) return;
45379
+ const c = (await this.d.comments(issueId)).find((x) => x.id === commentId);
45380
+ if (!c?.body?.trim()) return;
45381
+ const body = c.body.trim().slice(0, 12e3);
45382
+ const own2 = c.authorAgentId && this.d.hasBot(c.authorAgentId);
45383
+ const text2 = own2 ? body : `**${c.authorAgentId ? this.d.agentName(c.authorAgentId) : "A team member"}** commented on ${this.label(hit.link)}:
45384
+
45385
+ ${body}`;
45386
+ await this.d.post(own2 ? c.authorAgentId : hit.link.agentId, hit.channelId, "", text2);
45387
+ }
45388
+ /** The task changed: note a new status; on done or cancelled, archive and report back. */
45389
+ async updated(issueId) {
45390
+ const hit = await this.linked(issueId);
45391
+ if (!hit) return;
45392
+ const task2 = await this.d.task(issueId);
45393
+ if (!task2 || task2.status === hit.link.status) return;
45394
+ const { channelId, link } = hit;
45395
+ const known = link.status !== void 0;
45396
+ link.status = task2.status;
45397
+ if (!CLOSED.has(task2.status)) {
45398
+ await this.d.store.set(`worklink:${channelId}`, link);
45399
+ if (known) await this.d.post(link.agentId, channelId, "", `_${this.label(link)} is now ${STATUS_WORDS[task2.status] ?? task2.status}._`);
45400
+ return;
45401
+ }
45402
+ link.closed = true;
45403
+ await this.d.store.set(`worklink:${channelId}`, link);
45404
+ const last = (await this.d.comments(issueId)).at(-1)?.body?.trim();
45405
+ const outcome = last ? `
45406
+
45407
+ ${last.length > 1500 ? last.slice(0, 1500) + "\u2026" : last}` : "";
45408
+ const done = task2.status === "done";
45409
+ const name = await this.d.channelName(channelId).catch(() => "the work channel");
45410
+ await this.d.post(link.agentId, channelId, "", `${done ? ":white_check_mark:" : ":no_entry_sign:"} ${this.label(link)} is ${task2.status}. Archiving this channel; it stays readable under archived channels.`);
45411
+ await this.d.archive(channelId);
45412
+ if (link.origin) {
45413
+ await this.d.post(
45414
+ link.agentId,
45415
+ link.origin.channelId,
45416
+ link.origin.rootId,
45417
+ `${done ? ":white_check_mark: Done" : ":no_entry_sign: Cancelled"}: **${this.label(link)}** ${task2.title}${outcome}
45418
+
45419
+ _~${name} is archived._`
45420
+ );
45421
+ }
45041
45422
  }
45042
45423
  };
45043
45424
 
45425
+ // src/help.ts
45426
+ var HELP = `# Working in Mattermost
45427
+
45428
+ You have your own Mattermost account (a bot). Everything below runs as you, in your company's
45429
+ Mattermost, and you can only see and use channels you are a member of. Call these with your
45430
+ Paperclip API credentials, the same way you call the Paperclip API:
45431
+
45432
+ <your Paperclip API URL>/api/plugins/mattermost-bridge/api/mm/...
45433
+
45434
+ Answers are JSON. A 202 with "waiting_for_approval" means a person has been asked to approve;
45435
+ tell whoever you are working with and carry on. A 403 says why something is not allowed.
45436
+
45437
+ ## How to work
45438
+
45439
+ - **Pick the size of the work.** A quick question: answer in the thread. A task: create a Paperclip
45440
+ task (assign it to yourself or delegate it) and say so in the thread with its identifier. An
45441
+ investigation, incident or project, or anything involving several agents or more than a day:
45442
+ create the task, then open a work channel for it (below), with the person who asked and whoever
45443
+ you need. Name it for what it is: "inc checkout 5xx", "inv q3 conversion drop", "proj crm migration".
45444
+ - **Announce a work channel where the request came in**, in one message: what it is for, who you
45445
+ pulled in, and that you will report back there. When the task is done or cancelled the plugin
45446
+ archives the channel and posts the outcome back where you asked; you do not need to.
45447
+ - **Hand work to another agent by mentioning it** (@cto) in a channel or thread it is in; invite it
45448
+ first if it is not. Give it the context it needs in that message. It replies in the thread.
45449
+ Mention an agent only when you want it to act: every mention starts it.
45450
+ - **Say something before going quiet.** If an answer will take a while, post one line ("On it,
45451
+ checking what shipped this morning") and then do the work.
45452
+ - Do not write @channel, @all or @here unless everyone in the channel must be notified: it needs
45453
+ approval.
45454
+
45455
+ ## Calls
45456
+
45457
+ Reading (GET):
45458
+ - /mm/channels channels you are in
45459
+ - /mm/channels/{channel}/posts?limit=30 recent posts in a channel (oldest first)
45460
+ - /mm/posts/{postId}/thread a whole thread
45461
+ - /mm/channels/{channel}/members who is in a channel (people and agents)
45462
+ - /mm/search?q=words search messages in your channels
45463
+ - /mm/people?q=name find people and agents by name
45464
+ - /mm/requests/{id} an approval request you made
45465
+
45466
+ Talking (POST, JSON body):
45467
+ - /mm/post {"channel": "...", "text": "...", "threadId": "optional post id to reply to"}
45468
+ - /mm/react {"postId": "...", "emoji": "white_check_mark"}
45469
+ - /mm/dm {"person": "username", "text": "..."} only people allowed to talk to you
45470
+
45471
+ Channels (POST, JSON body):
45472
+ - /mm/channels {"name": "inc checkout 5xx", "purpose": "...", "people": ["username"],
45473
+ "agents": ["cto"], "taskId": "BAD-123 or task id", "private": true}
45474
+ Private by default. A public channel needs approval. With taskId, the channel is tied to the
45475
+ task: its comments and status show up there, and it is archived when the task closes.
45476
+ - /mm/channels/{channel}/invite {"people": [...], "agents": [...]} free on channels you created
45477
+ - /mm/channels/{channel}/update {"purpose": "...", "header": "...", "name": "..."} yours only
45478
+ - /mm/channels/{channel}/archive {} yours only
45479
+
45480
+ {channel} is a channel's id, its URL name (inc-checkout-5xx) or its display name.
45481
+ `;
45482
+
45483
+ // src/worker.ts
45484
+ import { randomUUID as randomUUID2 } from "node:crypto";
45485
+
45044
45486
  // src/bridge.ts
45045
- var PROGRESS_EVERY_MS = 2e3;
45487
+ var DEFAULT_TURN_LIMIT = 10;
45488
+ var TYPING_EVERY_MS = 3e3;
45489
+ var OLD_PLACEHOLDER = /^_(thinking…|.+ is working on it…)_/;
45046
45490
  var MAX_POST = 15e3;
45047
45491
  var MAX_HISTORY = 8e3;
45048
45492
  var MENTION = /(?:^|[^\w@])@([a-z0-9][a-z0-9._-]*)/gi;
@@ -45080,9 +45524,11 @@ var Bridge = class {
45080
45524
  files;
45081
45525
  choiceButtons;
45082
45526
  canTalk;
45527
+ turnLimit;
45083
45528
  constructor(roster, directory, agents, store, log = () => {
45084
- }, files = null, choiceButtons = null, canTalk = null) {
45529
+ }, files = null, choiceButtons = null, canTalk = null, turnLimit = DEFAULT_TURN_LIMIT) {
45085
45530
  this.files = files;
45531
+ this.turnLimit = turnLimit;
45086
45532
  this.canTalk = canTalk;
45087
45533
  this.choiceButtons = choiceButtons;
45088
45534
  this.roster = roster;
@@ -45112,6 +45558,42 @@ var Bridge = class {
45112
45558
  activeThread(agentId) {
45113
45559
  return this.active.get(agentId) ?? null;
45114
45560
  }
45561
+ /** The agent another agent's post addresses: the first other agent it mentions. */
45562
+ mentioned(evt, author) {
45563
+ if (evt.channelType === "D") return void 0;
45564
+ const roster = this.roster();
45565
+ for (const name of mentions(evt.post.message)) {
45566
+ const member = roster.byUsername(name);
45567
+ if (member && member.agentId !== author.agentId) return member;
45568
+ }
45569
+ return void 0;
45570
+ }
45571
+ /**
45572
+ * Agents may address agents, up to the turn limit per thread; a person posting in the
45573
+ * thread resets it. At the limit the addressed agent says once that it is waiting.
45574
+ */
45575
+ async resetTurns(rootId) {
45576
+ const turns = await this.store.get(`turns:${rootId}`);
45577
+ if (turns?.n) await this.store.set(`turns:${rootId}`, { n: 0 });
45578
+ }
45579
+ async allowAgentTurn(rootId, fromAgent, member, channelId) {
45580
+ if (!fromAgent) {
45581
+ await this.resetTurns(rootId);
45582
+ return true;
45583
+ }
45584
+ const key = `turns:${rootId}`;
45585
+ const turns = await this.store.get(key) ?? { n: 0 };
45586
+ if (turns.n >= this.turnLimit) {
45587
+ if (!turns.paused) {
45588
+ await this.store.set(key, { ...turns, paused: true });
45589
+ await member.chat.createPost(channelId, rootId, `_Pausing here: agents have traded ${turns.n} messages in this thread. Reply to let us carry on._`).catch(() => {
45590
+ });
45591
+ }
45592
+ return false;
45593
+ }
45594
+ await this.store.set(key, { n: turns.n + 1 });
45595
+ return true;
45596
+ }
45115
45597
  target(evt, thread) {
45116
45598
  const roster = this.roster();
45117
45599
  if (evt.channelType === "D") {
@@ -45128,7 +45610,7 @@ var Bridge = class {
45128
45610
  const roster = this.roster();
45129
45611
  const lines = [];
45130
45612
  for (const p of await this.directory.threadPosts(rootId)) {
45131
- if (p.id === currentId || p.type || !p.message.trim() || p.message.startsWith(THINKING)) continue;
45613
+ if (p.id === currentId || p.type || !p.message.trim() || OLD_PLACEHOLDER.test(p.message)) continue;
45132
45614
  const who = p.user_id === me.botUserId ? "You" : `@${roster.byBotId(p.user_id)?.username ?? (await this.directory.userName(p.user_id)).username}`;
45133
45615
  lines.push(`${who}: ${p.message}`);
45134
45616
  }
@@ -45139,15 +45621,21 @@ var Bridge = class {
45139
45621
  async process(evt, rootId) {
45140
45622
  const { post } = evt;
45141
45623
  const roster = this.roster();
45142
- if (roster.isBot(post.user_id) || post.type || post.props?.from_bot === "true" || post.props?.from_webhook === "true") return;
45624
+ const fromAgent = roster.isBot(post.user_id) ? roster.byBotId(post.user_id) : void 0;
45625
+ if (post.type || post.props?.from_webhook === "true" || post.props?.from_paperclip === "true") return;
45626
+ if (post.props?.from_bot === "true" && !fromAgent) return;
45143
45627
  const threadKey = `thread:${rootId}`;
45144
45628
  const thread = await this.store.get(threadKey);
45145
- const member = this.target(evt, thread);
45146
- if (!member) return;
45629
+ const member = fromAgent ? this.mentioned(evt, fromAgent) : this.target(evt, thread);
45630
+ if (!member) {
45631
+ if (!fromAgent && post.root_id) await this.resetTurns(rootId);
45632
+ return;
45633
+ }
45634
+ if (!await this.allowAgentTurn(rootId, fromAgent, member, post.channel_id)) return;
45147
45635
  const text2 = stripMention(post.message, member.username);
45148
45636
  const fileIds = this.files ? post.file_ids ?? [] : [];
45149
45637
  if (!text2 && !fileIds.length) return;
45150
- if (this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
45638
+ if (!fromAgent && this.canTalk && !await this.canTalk(post.user_id, member.agentId)) {
45151
45639
  await this.directory.notify(post.user_id, post.channel_id, post.root_id ?? "", `You don't have access to talk to ${member.agentName} here. Ask a Paperclip admin to add you in the Mattermost plugin's settings.`).catch(() => {
45152
45640
  });
45153
45641
  return;
@@ -45159,13 +45647,27 @@ var Bridge = class {
45159
45647
  const sender = await this.directory.userName(post.user_id);
45160
45648
  const where = isDirect ? "a direct message" : `#${evt.channelName}`;
45161
45649
  const history = post.root_id ? await this.history(post.root_id, post.id, member) : "";
45162
- const progress = new Progress();
45163
- const placeholder = await member.chat.createPost(post.channel_id, rootId, progress.render());
45164
- await member.chat.typing(post.channel_id, rootId).catch(() => {
45165
- });
45650
+ const typing = () => {
45651
+ void member.chat.typing(post.channel_id, rootId).catch(() => {
45652
+ });
45653
+ void member.chat.typing(post.channel_id, "").catch(() => {
45654
+ });
45655
+ };
45656
+ typing();
45657
+ const typingTimer = setInterval(typing, TYPING_EVERY_MS);
45658
+ try {
45659
+ await this.answer(evt, member, rootId, threadKey, thread, { sender, where, history, text: text2, fileIds });
45660
+ } finally {
45661
+ clearInterval(typingTimer);
45662
+ }
45663
+ }
45664
+ async answer(evt, member, rootId, threadKey, thread, m) {
45665
+ const { post } = evt;
45666
+ const { sender, where, history, text: text2, fileIds } = m;
45166
45667
  const attached = fileIds.length ? await this.files.receive(member.chat, post.id, fileIds) : [];
45167
45668
  const outbox = this.files ? await this.files.outbox(post.id).catch(() => null) : null;
45168
- const 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 ? `
45669
+ const fromAgent = this.roster().isBot(post.user_id);
45670
+ const prompt = `${sender.displayName} (@${sender.username}${fromAgent ? ", another agent" : ""}) wrote to you (@${member.username}) in ${where} on Mattermost. Your reply is posted back there, so answer in Markdown. To offer choices, end your reply with one line \`choices: First | Second\` (2 to ${MAX_CHOICES} short options); they are shown as buttons, and the pick comes back as the next message. You can also read channels, post, message people, mention other agents and open work channels in Mattermost: GET <your Paperclip API URL>/api/plugins/mattermost-bridge/api/mm/help shows how.` + (history ? `
45169
45671
 
45170
45672
  Earlier in this thread (oldest first):
45171
45673
  ${history}
@@ -45178,23 +45680,6 @@ Attached files, saved on disk (read them at these paths):
45178
45680
  ${attached.join("\n")}` : "") + (outbox ? `
45179
45681
 
45180
45682
  To send files back with your reply, save them in ${outbox}` : "");
45181
- let timer = null;
45182
- let edit = Promise.resolve();
45183
- let finished = false;
45184
- const onOutput = (chunk) => {
45185
- if (finished || !progress.feed(chunk) || timer) return;
45186
- timer = setTimeout(() => {
45187
- timer = null;
45188
- if (finished) return;
45189
- edit = edit.then(() => member.chat.patchPost(placeholder.id, progress.render())).catch(() => {
45190
- });
45191
- }, PROGRESS_EVERY_MS);
45192
- };
45193
- const settle2 = async () => {
45194
- finished = true;
45195
- if (timer) clearTimeout(timer);
45196
- await edit;
45197
- };
45198
45683
  let reply;
45199
45684
  const here = { channelId: post.channel_id, rootId };
45200
45685
  this.active.set(member.agentId, here);
@@ -45202,21 +45687,19 @@ To send files back with your reply, save them in ${outbox}` : "");
45202
45687
  let sessionId = thread && thread.agentId === member.agentId ? thread.sessionId : null;
45203
45688
  try {
45204
45689
  if (!sessionId) throw new Error("no session yet");
45205
- reply = await this.agents.ask(sessionId, prompt, onOutput);
45690
+ reply = await this.agents.ask(sessionId, prompt);
45206
45691
  } catch (first2) {
45207
45692
  if (sessionId) this.log("session unusable, starting a new one", { sessionId, err: String(first2) });
45208
45693
  sessionId = await this.agents.createSession(member.agentId, `mattermost:${rootId}`);
45209
45694
  await this.store.set(threadKey, { agentId: member.agentId, sessionId });
45210
- reply = await this.agents.ask(sessionId, prompt, onOutput);
45695
+ reply = await this.agents.ask(sessionId, prompt);
45211
45696
  }
45212
45697
  } catch (err) {
45213
45698
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45214
- await settle2();
45215
- await member.chat.patchPost(placeholder.id, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
45699
+ await member.chat.createPost(post.channel_id, rootId, `:warning: ${member.agentName} could not answer: ${String(err?.message ?? err).slice(0, 500)}`);
45216
45700
  return;
45217
45701
  }
45218
45702
  if (this.active.get(member.agentId) === here) this.active.delete(member.agentId);
45219
- await settle2();
45220
45703
  const { text: body, choices } = extractChoices(reply ?? "");
45221
45704
  const origin = { channelId: post.channel_id, rootId, channelType: evt.channelType, channelName: evt.channelName };
45222
45705
  const buttons = choices.length && this.choiceButtons ? this.choiceButtons(origin, choices) : null;
@@ -45224,7 +45707,7 @@ To send files back with your reply, save them in ${outbox}` : "");
45224
45707
 
45225
45708
  ${choices.map((c, i) => `${i + 1}. ${c}`).join("\n")}` : body;
45226
45709
  const [first, ...more] = splitMessage(shown.trim() || `_${member.agentName} finished without a reply._`);
45227
- await member.chat.patchPost(placeholder.id, first);
45710
+ await member.chat.createPost(post.channel_id, rootId, first);
45228
45711
  for (const part of more) await member.chat.createPost(post.channel_id, rootId, part);
45229
45712
  if (buttons) await member.chat.createPost(post.channel_id, rootId, "", buttons);
45230
45713
  if (outbox) {
@@ -45316,9 +45799,6 @@ var MattermostClient = class {
45316
45799
  async react(postId, emoji3) {
45317
45800
  await this.request("POST", "/reactions", { user_id: this.botUserId, post_id: postId, emoji_name: emoji3 });
45318
45801
  }
45319
- async patchPost(postId, message2) {
45320
- await this.request("PUT", `/posts/${postId}/patch`, { message: message2 });
45321
- }
45322
45802
  async typing(channelId, rootId) {
45323
45803
  await this.request("POST", "/users/me/typing", { channel_id: channelId, parent_id: rootId });
45324
45804
  }
@@ -45569,6 +46049,9 @@ var AgentBots = class {
45569
46049
  isBot: (id) => all.some((m) => m.botUserId === id)
45570
46050
  };
45571
46051
  }
46052
+ all() {
46053
+ return [...this.members.values()];
46054
+ }
45572
46055
  /** The Mattermost client of an agent's bot, for posts beyond chat replies. */
45573
46056
  clientOf(agentId) {
45574
46057
  return this.members.get(agentId)?.client;
@@ -45711,10 +46194,11 @@ var AgentBots = class {
45711
46194
  };
45712
46195
 
45713
46196
  // src/manifest.ts
46197
+ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth: "agent", capability: "api.routes.register" });
45714
46198
  var manifest = {
45715
46199
  id: "mattermost-bridge",
45716
46200
  apiVersion: 1,
45717
- version: "0.11.3",
46201
+ version: "0.13.0",
45718
46202
  displayName: "Mattermost",
45719
46203
  description: "Every agent becomes its own Mattermost bot. DM an agent or mention it in a channel and it answers in the thread, showing its steps as it works; follow-ups continue the conversation. Paperclip approvals arrive with Approve / Reject buttons.",
45720
46204
  author: "Haseeb Azhar",
@@ -45738,11 +46222,32 @@ var manifest = {
45738
46222
  "issue.interactions.read",
45739
46223
  "issue.interactions.respond",
45740
46224
  "issue.comments.create",
45741
- "issue.comments.create_human_attributed"
46225
+ "issue.comments.create_human_attributed",
46226
+ "issue.comments.read",
46227
+ "api.routes.register"
45742
46228
  ],
45743
46229
  entrypoints: { worker: "./dist/worker.js", ui: "./dist/ui" },
45744
46230
  // Replaces the generated form: approvers picked from members, URL detected and checked.
45745
46231
  ui: { slots: [{ type: "settingsPage", id: "mattermost-settings", displayName: "Mattermost", exportName: "SettingsPage" }] },
46232
+ // What agents do in Mattermost as their own bot (src/actions.ts). Agent-only: the host
46233
+ // checks the caller is an agent and scopes it to that agent's company.
46234
+ apiRoutes: [
46235
+ route("help", "GET", "/mm/help"),
46236
+ route("channels", "GET", "/mm/channels"),
46237
+ route("channel-posts", "GET", "/mm/channels/:channel/posts"),
46238
+ route("thread", "GET", "/mm/posts/:postId/thread"),
46239
+ route("channel-members", "GET", "/mm/channels/:channel/members"),
46240
+ route("search", "GET", "/mm/search"),
46241
+ route("people", "GET", "/mm/people"),
46242
+ route("request", "GET", "/mm/requests/:id"),
46243
+ route("post", "POST", "/mm/post"),
46244
+ route("react", "POST", "/mm/react"),
46245
+ route("dm", "POST", "/mm/dm"),
46246
+ route("create-channel", "POST", "/mm/channels"),
46247
+ route("invite", "POST", "/mm/channels/:channel/invite"),
46248
+ route("update-channel", "POST", "/mm/channels/:channel/update"),
46249
+ route("archive", "POST", "/mm/channels/:channel/archive")
46250
+ ],
45746
46251
  webhooks: [
45747
46252
  { endpointKey: "actions", displayName: "Mattermost buttons", description: "Approve / Reject clicks on approval posts. Every click carries a signature the plugin checks." }
45748
46253
  ],
@@ -45792,6 +46297,14 @@ var manifest = {
45792
46297
  description: "Each person by email, with `agents` listing the agent ids they may talk to (absent: all agents). People outside Paperclip have no userId.",
45793
46298
  items: { type: "object", properties: { userId: { type: "string" }, email: { type: "string" }, name: { type: "string" }, agents: { type: "array", items: { type: "string" } } }, required: ["email"] }
45794
46299
  },
46300
+ agentTurnLimit: {
46301
+ type: "integer",
46302
+ minimum: 1,
46303
+ maximum: 100,
46304
+ default: 10,
46305
+ title: "Agent-to-agent turns",
46306
+ description: "How many messages agents may trade in one thread, mentioning each other, before a person must reply."
46307
+ },
45795
46308
  approvers: {
45796
46309
  type: "array",
45797
46310
  title: "Approvers",
@@ -45833,7 +46346,10 @@ var Tenant = class {
45833
46346
  bridge = null;
45834
46347
  approvals = null;
45835
46348
  cards = null;
46349
+ actions = null;
46350
+ work = null;
45836
46351
  key = "";
46352
+ approvers = /* @__PURE__ */ new Map();
45837
46353
  timers = [];
45838
46354
  agents() {
45839
46355
  const companyId = this.companyId;
@@ -45842,14 +46358,13 @@ var Tenant = class {
45842
46358
  const key = `plugin:${PLUGIN_KEY}:session:${taskKey}`;
45843
46359
  return (await ctx.agents.sessions.create(agentId, companyId, { taskKey: key, reason: "Mattermost thread" })).sessionId;
45844
46360
  },
45845
- ask(sessionId, prompt, onOutput) {
46361
+ ask(sessionId, prompt) {
45846
46362
  return new Promise((resolve, reject) => {
45847
46363
  const timer = setTimeout(() => reject(new Error("no reply after 30 minutes")), ASK_TIMEOUT_MS);
45848
46364
  ctx.agents.sessions.sendMessage(sessionId, companyId, {
45849
46365
  prompt,
45850
46366
  reason: "Mattermost message",
45851
46367
  onEvent: (e) => {
45852
- if (e.eventType === "chunk" && e.stream === "stdout" && e.message) onOutput?.(e.message);
45853
46368
  if (e.eventType === "done") {
45854
46369
  clearTimeout(timer);
45855
46370
  resolve(e.message);
@@ -45915,8 +46430,8 @@ var Tenant = class {
45915
46430
  }
45916
46431
  }
45917
46432
  for (const team of teams) {
45918
- const list = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
45919
- const hit = list.find((ch) => ch.display_name.trim().toLowerCase() === wanted.toLowerCase());
46433
+ const list2 = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
46434
+ const hit = list2.find((ch) => ch.display_name.trim().toLowerCase() === wanted.toLowerCase());
45920
46435
  if (hit) return hit.id;
45921
46436
  }
45922
46437
  return null;
@@ -45940,6 +46455,7 @@ var Tenant = class {
45940
46455
  const paperclipUrl = typeof config2.paperclipUrl === "string" ? config2.paperclipUrl.trim().replace(/\/+$/, "") : "";
45941
46456
  const actionUrl = `${paperclipUrl}/api/plugins/${PLUGIN_KEY}/webhooks/actions`;
45942
46457
  const key = createHmac2("sha256", token).update("mattermost-approval-buttons").digest("hex");
46458
+ const canTalk = accessCheck(manager, config2);
45943
46459
  const bridge = new Bridge(
45944
46460
  () => roster.roster(),
45945
46461
  directoryOf(manager),
@@ -45948,7 +46464,8 @@ var Tenant = class {
45948
46464
  warn,
45949
46465
  diskFiles(),
45950
46466
  paperclipUrl ? (where, choices) => this.tag(choiceProps(key, actionUrl, where, choices)) : null,
45951
- accessCheck(manager, config2)
46467
+ canTalk,
46468
+ Number(config2.agentTurnLimit) > 0 ? Number(config2.agentTurnLimit) : DEFAULT_TURN_LIMIT
45952
46469
  );
45953
46470
  onPosted = (evt) => {
45954
46471
  void bridge.handle(evt);
@@ -45988,7 +46505,41 @@ var Tenant = class {
45988
46505
  channel: approvalsChannel,
45989
46506
  approvers
45990
46507
  }) : null;
45991
- Object.assign(this, { manager, bots: roster, bridge, approvals, cards, key });
46508
+ const poster = this.poster(manager, roster);
46509
+ const actions = new Actions({
46510
+ bot: (agentId) => roster.clientOf(agentId),
46511
+ manager,
46512
+ agents: () => roster.all().map((m) => ({ agentId: m.agentId, agentName: m.agentName, username: m.username, botUserId: m.botUserId })),
46513
+ canTalk: async (userId, agentId) => !canTalk || canTalk(userId, agentId),
46514
+ origin: (agentId) => bridge.activeThread(agentId),
46515
+ task: async (taskId) => await ctx.issues.get(taskId, companyId),
46516
+ askApproval: async (req) => {
46517
+ if (!paperclipUrl || !approvers.size) throw Object.assign(new Error("this needs a person's approval, and nobody can approve from Mattermost yet (the plugin needs its Paperclip public URL and approvers set)"), { status: 403 });
46518
+ const { message: message2, props } = requestCard(req, actionUrl, key);
46519
+ const targets = [req.origin, approvalsChannel ? await poster.channel(approvalsChannel).then((id) => id && id !== req.origin?.channelId ? { channelId: id, rootId: "" } : null) : null];
46520
+ const postIds = [];
46521
+ for (const where of targets) if (where) postIds.push(await poster.post(req.agentId, where, message2, props));
46522
+ await store.set(`reqcard:${req.id}`, postIds);
46523
+ },
46524
+ store,
46525
+ newId: () => randomUUID2()
46526
+ });
46527
+ const work = new WorkLinks({
46528
+ store,
46529
+ post: async (agentId, channelId, rootId, text2) => {
46530
+ const as = roster.clientOf(agentId) ?? manager;
46531
+ await as.request("POST", "/posts", { channel_id: channelId, root_id: rootId, message: text2, props: FROM_PAPERCLIP });
46532
+ },
46533
+ hasBot: (agentId) => Boolean(roster.clientOf(agentId)),
46534
+ agentName: (agentId) => agentName(agentId),
46535
+ archive: async (channelId) => {
46536
+ await manager.request("DELETE", `/channels/${channelId}`);
46537
+ },
46538
+ channelName: async (channelId) => (await manager.request("GET", `/channels/${channelId}`)).display_name,
46539
+ task: async (issueId) => await ctx.issues.get(issueId, companyId),
46540
+ comments: async (issueId) => (await ctx.issues.listComments(issueId, companyId)).map((c) => ({ id: c.id, body: c.body, authorAgentId: c.authorAgentId }))
46541
+ });
46542
+ Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers });
45992
46543
  this.status = `connecting to ${url2} as @${manager.botUsername}`;
45993
46544
  detached(() => {
45994
46545
  manager.connect(onPosted);
@@ -46011,7 +46562,28 @@ var Tenant = class {
46011
46562
  this.timers = [];
46012
46563
  this.bots?.close();
46013
46564
  this.manager?.close();
46014
- Object.assign(this, { manager: null, bots: null, bridge: null, approvals: null, cards: null });
46565
+ Object.assign(this, { manager: null, bots: null, bridge: null, approvals: null, cards: null, actions: null, work: null });
46566
+ }
46567
+ /** Approve / Reject on an agent's ask-first request (a broadcast, a public channel, an invite). */
46568
+ async decideRequest(req) {
46569
+ const { requestId, action, sig: sig2 } = req.context ?? {};
46570
+ if (!this.actions || !this.manager || !requestId || action !== "approve" && action !== "reject" || !signed(this.key, `req:${requestId}`, action, sig2)) return "This button is not valid.";
46571
+ const email3 = req.user_id ? (await this.manager.request("GET", `/users/${req.user_id}`).catch(() => ({ email: "" }))).email?.toLowerCase() : "";
46572
+ if (!email3 || !this.approvers.has(email3)) return "You are not set up to approve agent requests from Mattermost. Ask an admin to add your email to the plugin's approvers.";
46573
+ const decided = await this.actions.decide(requestId, action === "approve", req.user_name ? `@${req.user_name}` : email3);
46574
+ if (!decided) return "That request is gone.";
46575
+ const { message: message2, props } = requestCard(decided, "", this.key);
46576
+ const store = this.store();
46577
+ for (const postId of await store.get(`reqcard:${requestId}`) ?? []) {
46578
+ await this.manager.request("PUT", `/posts/${postId}/patch`, { message: message2, props }).catch(() => {
46579
+ });
46580
+ }
46581
+ return null;
46582
+ }
46583
+ /** An agent's call to one of the plugin's `/mm/...` routes. */
46584
+ async api(agentId, routeKey, params, input2) {
46585
+ if (!this.actions) return { status: 503, body: { error: `Mattermost is not connected for this company (${this.status})` } };
46586
+ return this.actions.handle(agentId, routeKey, params, input2);
46015
46587
  }
46016
46588
  async notify(req, notice) {
46017
46589
  if (notice && req.user_id && req.channel_id) {
@@ -46027,6 +46599,10 @@ var Tenant = class {
46027
46599
  if (this.cards) await this.notify(req, await this.cards.click(req).catch(failed));
46028
46600
  return;
46029
46601
  }
46602
+ if (kind === "request") {
46603
+ await this.notify(req, await this.decideRequest(req).catch(failed));
46604
+ return;
46605
+ }
46030
46606
  if (kind === "choice") {
46031
46607
  const choice = req.context;
46032
46608
  if (!this.manager || !this.bridge || !req.user_id || !req.post_id || !verifyChoice(this.key, choice)) return;
@@ -46044,6 +46620,28 @@ var Tenant = class {
46044
46620
  if (this.approvals) await this.notify(req, await this.approvals.click(req).catch(failed));
46045
46621
  }
46046
46622
  };
46623
+ function requestCard(r, actionUrl, key) {
46624
+ const pending2 = r.status === "pending";
46625
+ const button = (id, name, style) => ({
46626
+ id,
46627
+ name,
46628
+ style,
46629
+ type: "button",
46630
+ integration: { url: actionUrl, context: { kind: "request", requestId: r.id, action: id, sig: sign(key, `req:${r.id}`, id) } }
46631
+ });
46632
+ const outcome = r.status === "approved" ? ":white_check_mark: Approved" : r.status === "rejected" ? ":x: Rejected" : ":warning: Approved, but it failed";
46633
+ return {
46634
+ message: `**${r.agentName}** asks to ${r.summary}`,
46635
+ props: {
46636
+ attachments: [{
46637
+ color: pending2 ? "#f2a900" : r.status === "approved" ? "#3db887" : "#d24b4e",
46638
+ text: `Needs approval because ${r.why}.`,
46639
+ footer: pending2 ? "Approvers decide here" : `${outcome} by ${r.decidedBy}`,
46640
+ actions: pending2 ? [button("approve", "Approve", "primary"), button("reject", "Reject", "danger")] : []
46641
+ }]
46642
+ }
46643
+ };
46644
+ }
46047
46645
  function directoryOf(manager) {
46048
46646
  return {
46049
46647
  userName: (id) => manager.userName(id),
@@ -46096,8 +46694,8 @@ var plugin = definePlugin({
46096
46694
  const accounts = {};
46097
46695
  if (manager) {
46098
46696
  for (const team of await manager.request("GET", "/users/me/teams").catch(() => [])) {
46099
- const list = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
46100
- channels.push(...list.filter((ch) => ch.type === "O").map((ch) => ({ name: ch.name, displayName: ch.display_name })));
46697
+ const list2 = await manager.request("GET", `/teams/${team.id}/channels?per_page=200`).catch(() => []);
46698
+ channels.push(...list2.filter((ch) => ch.type === "O").map((ch) => ({ name: ch.name, displayName: ch.display_name })));
46101
46699
  }
46102
46700
  channels = channels.sort((a, b) => a.displayName.localeCompare(b.displayName));
46103
46701
  for (const email3 of emails) {
@@ -46111,6 +46709,13 @@ var plugin = definePlugin({
46111
46709
  ctx.events.on("approval.created", (e) => quietly("could not post approval", async () => {
46112
46710
  if (e.entityId) await tenants.get(e.companyId)?.approvals?.created(e.entityId);
46113
46711
  }));
46712
+ ctx.events.on("issue.comment.created", (e) => quietly("could not mirror a task comment", async () => {
46713
+ const commentId = e.payload?.commentId;
46714
+ if (e.entityId && commentId) await tenants.get(e.companyId)?.work?.comment(e.entityId, commentId);
46715
+ }));
46716
+ ctx.events.on("issue.updated", (e) => quietly("could not follow a task update", async () => {
46717
+ if (e.entityId) await tenants.get(e.companyId)?.work?.updated(e.entityId);
46718
+ }));
46114
46719
  ctx.events.on("approval.decided", (e) => quietly("could not update approval", async () => {
46115
46720
  if (e.entityId) await tenants.get(e.companyId)?.approvals?.decided(e.entityId);
46116
46721
  }));
@@ -46138,16 +46743,27 @@ var plugin = definePlugin({
46138
46743
  const tenant = typeof companyId === "string" ? tenants.get(companyId) : tenants.size === 1 ? [...tenants.values()][0] : void 0;
46139
46744
  await tenant?.click(req);
46140
46745
  },
46746
+ async onApiRequest(input2) {
46747
+ const agentId = input2.actor.agentId;
46748
+ if (input2.actor.actorType !== "agent" || !agentId) return { status: 403, body: { error: "Only agents call these routes." } };
46749
+ if (input2.routeKey === "help") return { status: 200, headers: { "content-type": "text/markdown; charset=utf-8" }, body: HELP };
46750
+ const tenant = tenants.get(input2.companyId);
46751
+ if (!tenant) return { status: 503, body: { error: "Mattermost is not set up for your company." } };
46752
+ const query = Object.fromEntries(Object.entries(input2.query).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]));
46753
+ const body = input2.body && typeof input2.body === "object" && !Array.isArray(input2.body) ? input2.body : {};
46754
+ const res = await tenant.api(agentId, input2.routeKey, input2.params, { ...query, ...body });
46755
+ return { status: res.status, body: res.body };
46756
+ },
46141
46757
  async onHealth() {
46142
46758
  if (!tenants.size) return { status: "error", message: "not configured: set up Mattermost in this plugin's settings for a company" };
46143
- let ok = true;
46759
+ let ok2 = true;
46144
46760
  const parts = [...tenants.values()].map((t) => {
46145
46761
  const live = t.bots?.connected ?? 0;
46146
46762
  const total = t.bots?.size ?? 0;
46147
- if (!t.manager?.connected || live !== total || !total) ok = false;
46763
+ if (!t.manager?.connected || live !== total || !total) ok2 = false;
46148
46764
  return `${t.status}; ${live}/${total} agent bots connected`;
46149
46765
  });
46150
- return { status: ok ? "ok" : "degraded", message: parts.join(" | ") + (versionNote ? ` | Note: ${versionNote}` : "") };
46766
+ return { status: ok2 ? "ok" : "degraded", message: parts.join(" | ") + (versionNote ? ` | Note: ${versionNote}` : "") };
46151
46767
  },
46152
46768
  async onShutdown() {
46153
46769
  for (const t of tenants.values()) t.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-plugin-mattermost",
3
- "version": "0.11.3",
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",