paperclip-plugin-mattermost 0.18.2 → 0.19.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
@@ -49,6 +49,7 @@ Or install it from **Settings → Plugins** in Paperclip.
49
49
  - **Mattermost admin token**: pick the secret from step 2.
50
50
  - **Paperclip public URL**: filled in from Paperclip's own settings. The page checks it by sending a test click through it.
51
51
  - **Approvals channel**: where approvals and decision cards are posted.
52
+ - **Channel per agent** (optional): send one agent's approvals, decision cards and computer help requests to its own channel, for example a client reporting agent's drafts to `#client-reporting`. Only approvers can decide them, but everyone in the channel sees them. A channel that cannot be found falls back to the approvals channel, with a note.
52
53
  - **Agents in Mattermost**: all agents, or the ones you pick.
53
54
  - **Who can talk to agents**: everyone, or the people you pick, each with all agents or some.
54
55
  - **Approvers**: who may decide approvals and cards from Mattermost. Their clicks count as their Paperclip decisions, matched to Mattermost by email.
@@ -85,7 +86,7 @@ PAPERCLIP_REF=<commit> node build.mjs # build for another Paperclip version
85
86
  Bump `version` in `package.json` and `src/manifest.ts`, commit, then tag and push:
86
87
 
87
88
  ```bash
88
- git tag -a v0.18.2 -m v0.18.2 && git push origin main v0.18.2
89
+ git tag -a v0.19.0 -m v0.19.0 && git push origin main v0.19.0
89
90
  ```
90
91
 
91
92
  GitHub Actions builds, tests and publishes to npm through npm trusted publishing, so no npm token is involved.
package/dist/manifest.js CHANGED
@@ -84,7 +84,7 @@ var route = (routeKey, method, path) => ({ routeKey, method, path, auth: "agent"
84
84
  var manifest = {
85
85
  id: "mattermost-bridge",
86
86
  apiVersion: 1,
87
- version: "0.18.2",
87
+ version: "0.19.0",
88
88
  displayName: "Mattermost",
89
89
  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.",
90
90
  author: "Haseeb Azhar",
@@ -170,6 +170,12 @@ var manifest = {
170
170
  title: "Approvals channel",
171
171
  description: "Channel name (as in its URL, e.g. approvals) where every approval is posted; one raised during a Mattermost conversation also appears in that thread."
172
172
  },
173
+ agentChannels: {
174
+ type: "array",
175
+ title: "Channel per agent",
176
+ description: "Agents whose approvals, decision cards and help requests go to their own channel instead of the approvals channel.",
177
+ items: { type: "object", properties: { agentId: { type: "string" }, channel: { type: "string" } }, required: ["agentId", "channel"] }
178
+ },
173
179
  agentsMode: {
174
180
  type: "string",
175
181
  enum: ["all", "selected"],
package/dist/ui/index.js CHANGED
@@ -122,6 +122,26 @@ function AccessList({ members, agents, grants, accounts, onChange }) {
122
122
  ] })
123
123
  ] });
124
124
  }
125
+ function AgentChannels({ agents, channels, value, onChange }) {
126
+ const named = (id) => agents.find((a) => a.id === id)?.name ?? "A removed agent";
127
+ const free = agents.filter((a) => !value.some((r) => r.agentId === a.id));
128
+ const update = (agentId, channel) => onChange(value.map((r) => r.agentId === agentId ? { agentId, channel } : r));
129
+ const picker = (r) => channels.length ? /* @__PURE__ */ jsxs("select", { style: s.input, value: r.channel, onChange: (e) => update(r.agentId, e.target.value), children: [
130
+ channels.some((ch) => ch.name === r.channel) ? null : /* @__PURE__ */ jsx("option", { value: r.channel, children: r.channel || "Pick a channel" }),
131
+ channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
132
+ ] }) : /* @__PURE__ */ jsx("input", { style: s.input, value: r.channel, onChange: (e) => update(r.agentId, e.target.value.trim()), placeholder: "channel name, e.g. client-reporting" });
133
+ return /* @__PURE__ */ jsxs("div", { style: { display: "grid", gap: 6 }, children: [
134
+ value.map((r) => /* @__PURE__ */ jsxs("div", { style: s.row, children: [
135
+ /* @__PURE__ */ jsx("span", { style: { minWidth: 160 }, children: named(r.agentId) }),
136
+ picker(r),
137
+ /* @__PURE__ */ jsx("button", { style: { ...s.button, padding: "4px 9px" }, title: "Use the approvals channel", onClick: () => onChange(value.filter((x) => x.agentId !== r.agentId)), children: "\u2715" })
138
+ ] }, r.agentId)),
139
+ free.length ? /* @__PURE__ */ jsxs("select", { style: { ...s.input, maxWidth: 320 }, value: "", onChange: (e) => e.target.value && onChange([...value, { agentId: e.target.value, channel: channels[0]?.name ?? "" }]), children: [
140
+ /* @__PURE__ */ jsx("option", { value: "", children: "Add an agent\u2026" }),
141
+ free.map((a) => /* @__PURE__ */ jsx("option", { value: a.id, children: a.name }, a.id))
142
+ ] }) : null
143
+ ] });
144
+ }
125
145
  function TokenHelp({ mattermostUrl }) {
126
146
  const base = /^https?:\/\/\S+/.test(mattermostUrl.trim()) ? mattermostUrl.trim().replace(/\/+$/, "") : null;
127
147
  const page = (path, label) => base ? /* @__PURE__ */ jsx("a", { href: `${base}${path}`, target: "_blank", rel: "noreferrer", children: label }) : /* @__PURE__ */ jsx("b", { children: label });
@@ -159,6 +179,7 @@ function validate(c) {
159
179
  if (!url) errors.mattermostUrl = "Enter your Mattermost address.";
160
180
  else if (!/^https?:\/\/[^\s/]+/.test(url)) errors.mattermostUrl = "Enter a full address starting with https://";
161
181
  if (!c.botToken?.secretId) errors.botToken = "Choose the secret that holds the Mattermost admin token.";
182
+ if (Array.isArray(c.agentChannels) && c.agentChannels.some((r) => !String(r.channel ?? "").trim())) errors.agentChannels = "Pick a channel for every agent listed, or remove it.";
162
183
  return errors;
163
184
  }
164
185
  function saveErrorText(raw) {
@@ -340,6 +361,15 @@ function SettingsPage({ context }) {
340
361
  /* @__PURE__ */ jsx("option", { value: "", children: "None: only in the agent's thread" }),
341
362
  setup.data.channels.map((ch) => /* @__PURE__ */ jsx("option", { value: ch.name, children: ch.displayName }, ch.name))
342
363
  ] }) : /* @__PURE__ */ jsx("input", { style: s.input, value: String(config.approvalsChannel ?? ""), onChange: (e) => set({ approvalsChannel: e.target.value }), placeholder: "channel name, e.g. approvals" }) }),
364
+ /* @__PURE__ */ jsx(Field, { label: "Channel per agent", error: fieldErrors.agentChannels, help: "Send one agent's approvals, decision cards and computer help requests to its own channel, for example a client reporting agent's drafts to #client-reporting. Only approvers can decide them, but everyone in that channel sees them. If its channel cannot be found, they go to the approvals channel with a note.", children: /* @__PURE__ */ jsx(
365
+ AgentChannels,
366
+ {
367
+ agents,
368
+ channels: setup.data?.channels ?? [],
369
+ value: Array.isArray(config.agentChannels) ? config.agentChannels : [],
370
+ onChange: (next) => set({ agentChannels: next })
371
+ }
372
+ ) }),
343
373
  /* @__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
374
  "input",
345
375
  {
package/dist/worker.js CHANGED
@@ -44640,6 +44640,24 @@ import { createHmac as createHmac2 } from "node:crypto";
44640
44640
 
44641
44641
  // src/approvals.ts
44642
44642
  import { createHmac, timingSafeEqual } from "node:crypto";
44643
+ function parseAgentChannels(value) {
44644
+ const out = {};
44645
+ if (Array.isArray(value)) for (const r of value) {
44646
+ if (typeof r?.agentId === "string" && typeof r.channel === "string" && r.channel.trim()) out[r.agentId] = r.channel.trim();
44647
+ }
44648
+ return out;
44649
+ }
44650
+ function decisionChannels(poster, perAgent, fallback) {
44651
+ return async (agentId) => {
44652
+ const own2 = agentId ? perAgent[agentId] : void 0;
44653
+ if (own2) {
44654
+ const id2 = await poster.channel(own2);
44655
+ if (id2) return { channelId: id2 };
44656
+ }
44657
+ const id = fallback ? await poster.channel(fallback) : null;
44658
+ return id ? { channelId: id, ...own2 ? { note: `Posted here because this agent's channel "${own2}" was not found.` } : {} } : null;
44659
+ };
44660
+ }
44643
44661
  var TYPE_LABEL = {
44644
44662
  request_board_approval: "Approval requested",
44645
44663
  hire_agent: "Hire request",
@@ -44715,12 +44733,13 @@ var Approvals = class {
44715
44733
  const targets = [];
44716
44734
  const thread = a.requestedByAgentId ? d.activeThread(a.requestedByAgentId) : null;
44717
44735
  if (thread) targets.push(thread);
44718
- const channelId = d.fallbackChannel ? await d.poster.channel(d.fallbackChannel) : null;
44719
- if (channelId && channelId !== thread?.channelId) targets.push({ channelId, rootId: "" });
44736
+ const home = await d.channelFor(a.requestedByAgentId);
44737
+ if (home && home.channelId !== thread?.channelId) targets.push({ channelId: home.channelId, rootId: "", note: home.note });
44720
44738
  if (!targets.length) return;
44721
44739
  const { message: message2, props } = render(a, d.agentName(a.requestedByAgentId), d.actionUrl, d.key);
44722
44740
  const postIds = [];
44723
- for (const where of targets) postIds.push(await d.poster.post(a.requestedByAgentId, where, message2, props));
44741
+ for (const { note, ...where } of targets) postIds.push(await d.poster.post(a.requestedByAgentId, where, note ? `${message2}
44742
+ _${note}_` : message2, props));
44724
44743
  await d.store.set(`approval:${approvalId}`, { postIds });
44725
44744
  }
44726
44745
  /** `approval.decided` (here or in Paperclip): drop the buttons, show the outcome. */
@@ -44862,8 +44881,11 @@ var Cards = class {
44862
44881
  }
44863
44882
  async pass() {
44864
44883
  const d = this.d;
44865
- const channelId = await d.poster.channel(d.channel);
44866
- if (!channelId) return;
44884
+ const homes = /* @__PURE__ */ new Map();
44885
+ const home = async (agentId) => {
44886
+ if (!homes.has(agentId)) homes.set(agentId, await d.channelFor(agentId));
44887
+ return homes.get(agentId) ?? null;
44888
+ };
44867
44889
  const now = (d.now ?? (() => /* @__PURE__ */ new Date()))();
44868
44890
  let since = await d.store.get("cards:since");
44869
44891
  if (!since) {
@@ -44882,8 +44904,12 @@ var Cards = class {
44882
44904
  delete posted[c.id];
44883
44905
  }
44884
44906
  } else if (c.status === "pending" && new Date(c.createdAt).getTime() > Date.parse(since)) {
44885
- const { message: message2, props } = renderCard(c, issue2, d.agentName(c.createdByAgentId ?? issue2.assigneeAgentId ?? null), d);
44886
- posted[c.id] = { postId: await d.poster.post(c.createdByAgentId ?? issue2.assigneeAgentId ?? null, { channelId, rootId: "" }, message2, props), issueId: issue2.id };
44907
+ const agentId = c.createdByAgentId ?? issue2.assigneeAgentId ?? null;
44908
+ const where = await home(agentId);
44909
+ if (!where) continue;
44910
+ const { message: message2, props } = renderCard(c, issue2, d.agentName(agentId), d);
44911
+ posted[c.id] = { postId: await d.poster.post(agentId, { channelId: where.channelId, rootId: "" }, where.note ? `${message2}
44912
+ _${where.note}_` : message2, props), issueId: issue2.id };
44887
44913
  }
44888
44914
  }
44889
44915
  }
@@ -45365,6 +45391,8 @@ var STATUS_WORDS = {
45365
45391
  };
45366
45392
  var WorkLinks = class {
45367
45393
  d;
45394
+ /** One task's updates, one at a time: Paperclip sends several at once and each would report the change. */
45395
+ queue = /* @__PURE__ */ new Map();
45368
45396
  constructor(deps) {
45369
45397
  this.d = deps;
45370
45398
  }
@@ -45394,12 +45422,25 @@ ${body}`;
45394
45422
  }
45395
45423
  await this.d.post(hit.link.agentId, hit.channelId, "", c.authorAgentId === hit.link.agentId ? body : quoted);
45396
45424
  }
45425
+ serial(key, run) {
45426
+ const next = (this.queue.get(key) ?? Promise.resolve()).catch(() => {
45427
+ }).then(run);
45428
+ this.queue.set(key, next);
45429
+ void next.catch(() => {
45430
+ }).finally(() => {
45431
+ if (this.queue.get(key) === next) this.queue.delete(key);
45432
+ });
45433
+ return next;
45434
+ }
45397
45435
  /**
45398
45436
  * A task asked for in a Mattermost thread (created by the agent while answering there): when it is
45399
45437
  * done, cancelled or blocked, its outcome (the last comment) is posted back in that thread, so the
45400
45438
  * result always reaches the conversation it came from.
45401
45439
  */
45402
- async threadUpdate(issueId) {
45440
+ threadUpdate(issueId) {
45441
+ return this.serial(`thread:${issueId}`, () => this.reportToThread(issueId));
45442
+ }
45443
+ async reportToThread(issueId) {
45403
45444
  const at = await this.d.store.get(`issuethread:${issueId}`);
45404
45445
  if (!at) return;
45405
45446
  const task2 = await this.d.task(issueId);
@@ -45420,7 +45461,10 @@ ${last.length > 3e3 ? last.slice(0, 3e3) + "\u2026" : last}` : "";
45420
45461
  }
45421
45462
  }
45422
45463
  /** The task changed: note a new status; on done or cancelled, archive and report back. */
45423
- async updated(issueId) {
45464
+ updated(issueId) {
45465
+ return this.serial(`link:${issueId}`, () => this.followLink(issueId));
45466
+ }
45467
+ async followLink(issueId) {
45424
45468
  const hit = await this.linked(issueId);
45425
45469
  if (!hit) return;
45426
45470
  const task2 = await this.d.task(issueId);
@@ -46376,7 +46420,7 @@ var route = (routeKey, method, path3) => ({ routeKey, method, path: path3, auth:
46376
46420
  var manifest = {
46377
46421
  id: "mattermost-bridge",
46378
46422
  apiVersion: 1,
46379
- version: "0.18.2",
46423
+ version: "0.19.0",
46380
46424
  displayName: "Mattermost",
46381
46425
  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.",
46382
46426
  author: "Haseeb Azhar",
@@ -46462,6 +46506,12 @@ var manifest = {
46462
46506
  title: "Approvals channel",
46463
46507
  description: "Channel name (as in its URL, e.g. approvals) where every approval is posted; one raised during a Mattermost conversation also appears in that thread."
46464
46508
  },
46509
+ agentChannels: {
46510
+ type: "array",
46511
+ title: "Channel per agent",
46512
+ description: "Agents whose approvals, decision cards and help requests go to their own channel instead of the approvals channel.",
46513
+ items: { type: "object", properties: { agentId: { type: "string" }, channel: { type: "string" } }, required: ["agentId", "channel"] }
46514
+ },
46465
46515
  agentsMode: {
46466
46516
  type: "string",
46467
46517
  enum: ["all", "selected"],
@@ -46535,7 +46585,7 @@ var Tenant = class {
46535
46585
  key = "";
46536
46586
  approvers = /* @__PURE__ */ new Map();
46537
46587
  paperclipUrl = "";
46538
- approvalsChannel = "";
46588
+ channelFor = async () => null;
46539
46589
  timers = [];
46540
46590
  agents() {
46541
46591
  const companyId = this.companyId;
@@ -46665,6 +46715,8 @@ var Tenant = class {
46665
46715
  };
46666
46716
  const approvers = parseApprovers(config2.approvers);
46667
46717
  const approvalsChannel = typeof config2.approvalsChannel === "string" ? config2.approvalsChannel.trim() : "";
46718
+ const agentChannels = parseAgentChannels(config2.agentChannels);
46719
+ const channelFor = decisionChannels(this.poster(manager, roster), agentChannels, approvalsChannel);
46668
46720
  const agentName = (agentId) => agentId && roster.roster().byAgentId(agentId)?.agentName || "Paperclip";
46669
46721
  const approvals = paperclipUrl ? new Approvals({
46670
46722
  poster: this.poster(manager, roster),
@@ -46677,10 +46729,10 @@ var Tenant = class {
46677
46729
  store,
46678
46730
  actionUrl,
46679
46731
  key,
46680
- fallbackChannel: approvalsChannel,
46732
+ channelFor,
46681
46733
  approvers
46682
46734
  }) : null;
46683
- const cards = paperclipUrl && approvalsChannel ? new Cards({
46735
+ const cards = paperclipUrl && (approvalsChannel || Object.keys(agentChannels).length) ? new Cards({
46684
46736
  poster: this.poster(manager, roster),
46685
46737
  openIssues: async () => (await Promise.all(OPEN_STATUSES.map((status) => ctx.issues.list({ companyId, status, limit: 500 })))).flat(),
46686
46738
  cards: async (issueId) => await ctx.issues.listInteractions(issueId, companyId),
@@ -46695,7 +46747,7 @@ var Tenant = class {
46695
46747
  actionUrl,
46696
46748
  key,
46697
46749
  paperclipUrl,
46698
- channel: approvalsChannel,
46750
+ channelFor,
46699
46751
  approvers
46700
46752
  }) : null;
46701
46753
  const poster = this.poster(manager, roster);
@@ -46718,7 +46770,8 @@ var Tenant = class {
46718
46770
  askApproval: async (req) => {
46719
46771
  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 });
46720
46772
  const { message: message2, props } = requestCard(req, actionUrl, key);
46721
- const targets = [req.origin, approvalsChannel ? await poster.channel(approvalsChannel).then((id) => id && id !== req.origin?.channelId ? { channelId: id, rootId: "" } : null) : null];
46773
+ const home = await channelFor(req.agentId);
46774
+ const targets = [req.origin, home && home.channelId !== req.origin?.channelId ? { channelId: home.channelId, rootId: "" } : null];
46722
46775
  const postIds = [];
46723
46776
  for (const where of targets) if (where) postIds.push(await poster.post(req.agentId, where, message2, props));
46724
46777
  await store.set(`reqcard:${req.id}`, postIds);
@@ -46742,7 +46795,7 @@ var Tenant = class {
46742
46795
  task: async (issueId) => await ctx.issues.get(issueId, companyId),
46743
46796
  comments: async (issueId) => (await ctx.issues.listComments(issueId, companyId)).map((c) => ({ id: c.id, body: c.body, authorAgentId: c.authorAgentId }))
46744
46797
  });
46745
- Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers, paperclipUrl, approvalsChannel });
46798
+ Object.assign(this, { manager, bots: roster, bridge, approvals, cards, actions, work, key, approvers, paperclipUrl, channelFor });
46746
46799
  this.status = `connecting to ${url2} as @${manager.botUsername}`;
46747
46800
  detached(() => {
46748
46801
  manager.connect(onPosted);
@@ -46795,7 +46848,7 @@ var Tenant = class {
46795
46848
  async computerNeedsPerson(p) {
46796
46849
  if (!this.manager || !this.bots || !this.bridge || !p.agentId) return;
46797
46850
  const poster = this.poster(this.manager, this.bots);
46798
- const where = this.bridge.activeThread(p.agentId) ?? (this.approvalsChannel ? await poster.channel(this.approvalsChannel).then((id) => id ? { channelId: id, rootId: "" } : null) : null);
46851
+ const where = this.bridge.activeThread(p.agentId) ?? await this.channelFor(p.agentId).then((home) => home ? { channelId: home.channelId, rootId: "" } : null);
46799
46852
  if (!where) return;
46800
46853
  const link = this.paperclipUrl && p.path ? `${this.paperclipUrl}${p.path}` : null;
46801
46854
  const on = p.url ? ` on ${p.url}` : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "paperclip-plugin-mattermost",
3
- "version": "0.18.2",
3
+ "version": "0.19.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",