loopable-cli 0.2.0 → 0.3.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.
Files changed (138) hide show
  1. package/.output/nitro.json +1 -1
  2. package/.output/public/assets/_connectorId-CDCuSgpN.js +1 -0
  3. package/.output/public/assets/_loopId-DOnIsVTW.js +1 -0
  4. package/.output/public/assets/_taskId-BO5MZnlZ.js +1 -0
  5. package/.output/public/assets/agents-CBozi7oi.js +1 -0
  6. package/.output/public/assets/alert-Bn_wjgTX.js +1 -0
  7. package/.output/public/assets/button-K3ypIhAZ.js +1 -0
  8. package/.output/public/assets/card-DiVnaTIE.js +1 -0
  9. package/.output/public/assets/chevron-right-BrctLtFK.js +1 -0
  10. package/.output/public/assets/connection-status-DKWelvLO.js +1 -0
  11. package/.output/public/assets/connector-icon-CEbdE71G.js +1 -0
  12. package/.output/public/assets/connectors-JyX3IzJj.js +1 -0
  13. package/.output/public/assets/gaps-PjmcbaWo.js +1 -0
  14. package/.output/public/assets/inbox-CujRR0Cz.js +1 -0
  15. package/.output/public/assets/index-C9SQ_Baq.js +16 -0
  16. package/.output/public/assets/loop-form-Dw3cuTzw.js +1 -0
  17. package/.output/public/assets/loops-DcEfZa2_.js +1 -0
  18. package/.output/public/assets/manifests-yJpevyye.js +1 -0
  19. package/.output/public/assets/new-CwsnNuRW.js +1 -0
  20. package/.output/public/assets/page-bvnfRMum.js +1 -0
  21. package/.output/public/assets/play-yHLs4DjK.js +1 -0
  22. package/.output/public/assets/plus-Ber25lrU.js +1 -0
  23. package/.output/public/assets/preload-helper-BoU_7EQC.js +3 -0
  24. package/.output/public/assets/refresh-cw-BzSY7M8I.js +1 -0
  25. package/.output/public/assets/rotate-ccw-DLv9EfCX.js +1 -0
  26. package/.output/public/assets/runners-DzX-RU3i.js +2 -0
  27. package/.output/public/assets/select-C9grIFwG.js +1 -0
  28. package/.output/public/assets/setting-fields-DUc3npgh.js +3 -0
  29. package/.output/public/assets/styles-B9B98zLk.css +2 -0
  30. package/.output/public/assets/switch-DwABVI6d.js +1 -0
  31. package/.output/public/assets/task-list-Ddk8-qt7.js +1 -0
  32. package/.output/public/assets/textarea-Ds-gIYUQ.js +1 -0
  33. package/.output/public/assets/time-goBWd-5J.js +1 -0
  34. package/.output/public/assets/use-live-tasks-DZNNuECy.js +1 -0
  35. package/.output/public/assets/useCompositeListItem-P4Nmmn7j.js +1 -0
  36. package/.output/public/assets/usePositioner-B7i9Ejzh.js +1 -0
  37. package/.output/server/_chunks/ssr-renderer.mjs +1 -1
  38. package/.output/server/_connectorId-COuQN02a.mjs +512 -0
  39. package/.output/server/_libs/@base-ui/react+[...].mjs +1410 -804
  40. package/.output/server/_libs/@tanstack/react-router+[...].mjs +1 -1
  41. package/.output/server/_libs/lucide-react.mjs +93 -84
  42. package/.output/server/_libs/next-themes.mjs +1 -1
  43. package/.output/server/_libs/sonner.mjs +1 -1
  44. package/.output/server/_loopId-CZh6_Yhr.mjs +310 -0
  45. package/.output/server/_ssr/{callback-D20cVD2w.mjs → access-DTttr563.mjs} +37 -20
  46. package/.output/server/_ssr/{agents-CrUmIQpi.mjs → agents-B_qo1gpL.mjs} +196 -7
  47. package/.output/server/_ssr/{agents-Dms_6TBF.mjs → agents-Bz10gz_m.mjs} +8 -8
  48. package/.output/server/_ssr/agents-CjbOUp3Z.mjs +206 -0
  49. package/.output/server/_ssr/{alert-DQvzE7bu.mjs → alert-C2WBWfzo.mjs} +2 -2
  50. package/.output/server/_ssr/card-r1cV-PCN.mjs +36 -0
  51. package/.output/server/_ssr/{client-BtVRVhQ2.mjs → client-Dk3SD8Il.mjs} +5 -1
  52. package/.output/server/_ssr/connection-status-BogAL-Fj.mjs +32 -0
  53. package/.output/server/_ssr/{connections-ypex3qUd.mjs → connections-Cqvb6Gw2.mjs} +1109 -263
  54. package/.output/server/_ssr/connector-icon-DrhVbhs6.mjs +190 -0
  55. package/.output/server/_ssr/connectors-BfS5tQKr.mjs +81 -0
  56. package/.output/server/_ssr/{connectors-BZly0yGF.mjs → connectors-DJI-rzPU.mjs} +21 -10
  57. package/.output/server/_ssr/{createServerRpc-DPDrnFdH.mjs → createServerRpc-DDrp2nLT.mjs} +2 -2
  58. package/.output/server/_ssr/{gaps-D2Zh8sEo.mjs → gaps-CcvHiLcj.mjs} +18 -10
  59. package/.output/server/_ssr/inbox-D1ef761v.mjs +178 -0
  60. package/.output/server/_ssr/loop-form-DWwOR05R.mjs +346 -0
  61. package/.output/server/_ssr/{loops-Og_G3KyZ.mjs → loops-6WhUe_lc.mjs} +228 -25
  62. package/.output/server/_ssr/loops-DHPjzG-t.mjs +217 -0
  63. package/.output/server/_ssr/new-ChKBdcXJ.mjs +245 -0
  64. package/.output/server/_ssr/page-DZVxKvdn.mjs +120 -0
  65. package/.output/server/_ssr/{router-BbrBz36P.mjs → router-DdwREiOf.mjs} +167 -489
  66. package/.output/server/_ssr/runners-CK9Cx9fS.mjs +217 -0
  67. package/.output/server/_ssr/runners-CrznFPHt.mjs +140 -0
  68. package/.output/server/_ssr/runners-W-pgo5yo.mjs +55 -0
  69. package/.output/server/_ssr/{secrets-CRnpwGBh.mjs → secrets-CV5scFlt.mjs} +2 -2
  70. package/.output/server/_ssr/select-BtndHhHb.mjs +76 -0
  71. package/.output/server/_ssr/{setting-fields-mpXfGftI.mjs → setting-fields-LNgywAW7.mjs} +25 -20
  72. package/.output/server/_ssr/ssr.mjs +49 -40
  73. package/.output/server/_ssr/{start-BgTj-h_C.mjs → start-C_j-e4vF.mjs} +10 -6
  74. package/.output/server/_ssr/{switch-1pnd_bPJ.mjs → switch-odxNO_S4.mjs} +3 -3
  75. package/.output/server/_ssr/task-list-Dnmq_WeE.mjs +150 -0
  76. package/.output/server/_ssr/{tasks-LIi-W4cy.mjs → tasks-D-kXLgVP.mjs} +13 -13
  77. package/.output/server/_ssr/{tasks-B_QmDFVs.mjs → tasks-D7aUvA33.mjs} +55 -17
  78. package/.output/server/_ssr/{textarea-CHJTKtsJ.mjs → textarea-D2c_vTIC.mjs} +2 -2
  79. package/.output/server/_ssr/time-Cy8TZP0Z.mjs +13 -0
  80. package/.output/server/_ssr/use-live-tasks-BzhTkEOL.mjs +40 -0
  81. package/.output/server/_tanstack-start-manifest_v-2HUdn-2E.mjs +166 -0
  82. package/.output/server/_taskId-D3KkNJW1.mjs +263 -0
  83. package/.output/server/index.mjs +204 -162
  84. package/README.md +54 -19
  85. package/dist/cli.mjs +185 -67
  86. package/dist/dispatcher.mjs +1443 -277
  87. package/dist/runner.mjs +333 -46
  88. package/drizzle/0025_prompt_tasks_done.sql +2 -0
  89. package/drizzle/0026_guidance_into_prompt.sql +3 -0
  90. package/drizzle/0027_relative_folders.sql +5 -0
  91. package/drizzle/0028_runner_version.sql +5 -0
  92. package/drizzle/meta/_journal.json +29 -1
  93. package/package.json +23 -17
  94. package/.output/public/assets/_connectorId-m67WbiJ8.js +0 -1
  95. package/.output/public/assets/_loopId-Bf4K5sdd.js +0 -1
  96. package/.output/public/assets/_taskId-eCKkcVOx.js +0 -1
  97. package/.output/public/assets/alert-BT2EVMJP.js +0 -1
  98. package/.output/public/assets/arrow-left-CWUZt6k2.js +0 -1
  99. package/.output/public/assets/arrow-right-dy_oOg_c.js +0 -1
  100. package/.output/public/assets/connection-status-DCqqmY_6.js +0 -1
  101. package/.output/public/assets/connector-icon-qGpNsPgH.js +0 -1
  102. package/.output/public/assets/connectors-DxXlc9zF.js +0 -1
  103. package/.output/public/assets/external-link-DC11orFA.js +0 -1
  104. package/.output/public/assets/gaps-CoTA-PzA.js +0 -1
  105. package/.output/public/assets/inbox-CLEXUXCI.js +0 -1
  106. package/.output/public/assets/index-Cndsw84p.js +0 -16
  107. package/.output/public/assets/loader-B4Epchm_.js +0 -1
  108. package/.output/public/assets/loop-form-BvizlZdR.js +0 -1
  109. package/.output/public/assets/loops-Dbe_mf5T.js +0 -1
  110. package/.output/public/assets/new-DPEaXKAY.js +0 -1
  111. package/.output/public/assets/play-DD4xQgvi.js +0 -1
  112. package/.output/public/assets/preload-helper-CfdAYKD7.js +0 -3
  113. package/.output/public/assets/rolldown-runtime-CbXtAM7H.js +0 -1
  114. package/.output/public/assets/runners-BVBggS5B.js +0 -1
  115. package/.output/public/assets/select-Cppu-hti.js +0 -1
  116. package/.output/public/assets/setting-fields-BT1naVJX.js +0 -3
  117. package/.output/public/assets/styles-D3sonCfX.css +0 -2
  118. package/.output/public/assets/switch-hRIbNGOj.js +0 -1
  119. package/.output/public/assets/task-list-CrptnI1N.js +0 -1
  120. package/.output/public/assets/textarea-Fyuzi6Vp.js +0 -1
  121. package/.output/public/assets/trash-2-BXZzBsoV.js +0 -1
  122. package/.output/public/assets/useValueChanged-VVyq4hb9.js +0 -1
  123. package/.output/server/_connectorId-DcSnoRjk.mjs +0 -421
  124. package/.output/server/_loopId-G_TAkYtS.mjs +0 -272
  125. package/.output/server/_ssr/access-B7TcOZ7F.mjs +0 -42
  126. package/.output/server/_ssr/connection-status-BZr3Y-IC.mjs +0 -31
  127. package/.output/server/_ssr/connector-icon-CFrrHjzR.mjs +0 -37
  128. package/.output/server/_ssr/connectors-4yzKb0KG.mjs +0 -106
  129. package/.output/server/_ssr/inbox-C2VBGLa5.mjs +0 -114
  130. package/.output/server/_ssr/loop-form-D-U-kU4k.mjs +0 -413
  131. package/.output/server/_ssr/loops-D0XJ8eBb.mjs +0 -222
  132. package/.output/server/_ssr/new-CajvgIog.mjs +0 -169
  133. package/.output/server/_ssr/runners-B_WuF8UJ.mjs +0 -32
  134. package/.output/server/_ssr/runners-D-0_lfiq.mjs +0 -106
  135. package/.output/server/_ssr/runners-zqF_zbEc.mjs +0 -116
  136. package/.output/server/_ssr/task-list-BnQJ94Dk.mjs +0 -187
  137. package/.output/server/_tanstack-start-manifest_v-BCHAazE8.mjs +0 -129
  138. package/.output/server/_taskId-CIv0ajIx.mjs +0 -256
@@ -1,10 +1,10 @@
1
1
  import { d as and, f as eq, h as lt, l as asc } from "../_libs/drizzle-orm.mjs";
2
- import { a as connections, i as authAttempts, s as db } from "./client-BtVRVhQ2.mjs";
3
- import { i as readCredential, s as writeCredential, t as deleteCredential } from "./secrets-CRnpwGBh.mjs";
2
+ import { a as connections, i as authAttempts, s as db } from "./client-Dk3SD8Il.mjs";
3
+ import { i as readCredential, s as writeCredential, t as deleteCredential } from "./secrets-CV5scFlt.mjs";
4
4
  import { createHash, randomBytes, randomUUID } from "node:crypto";
5
5
  import { existsSync, readFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
- //#region node_modules/.nitro/vite/services/ssr/assets/connections-ypex3qUd.js
7
+ //#region node_modules/.nitro/vite/services/ssr/assets/connections-Cqvb6Gw2.js
8
8
  function defineManifest(manifest) {
9
9
  return manifest;
10
10
  }
@@ -16,7 +16,7 @@ var repositories = {
16
16
  key: "repositories",
17
17
  kind: "string_list",
18
18
  label: "Repositories",
19
- help: "owner/name, one per line. Wildcards work: acme/* or */*. Leave empty for every repository you can see.",
19
+ help: "owner/name, one per line. acme/* works. Empty means all.",
20
20
  placeholder: "acme/web"
21
21
  };
22
22
  var ignoreDrafts = {
@@ -29,7 +29,7 @@ var ignoreBots = {
29
29
  key: "ignoreBots",
30
30
  kind: "boolean",
31
31
  label: "Skip pull requests opened by bots",
32
- help: "Dependabot and friends open a great many, and each one costs a full run.",
32
+ help: "Such as Dependabot.",
33
33
  default: true
34
34
  };
35
35
  /**
@@ -69,7 +69,7 @@ var issueTarget = {
69
69
  key: "issue",
70
70
  kind: "text",
71
71
  label: "Issue or pull request",
72
- help: "A GitHub URL. Leave empty to write back to whatever triggered the loop.",
72
+ help: "Empty replies where it was asked. Or a GitHub issue URL.",
73
73
  placeholder: "https://github.com/acme/web/issues/12"
74
74
  };
75
75
  /**
@@ -91,115 +91,80 @@ var REVIEW_PROMPT = [
91
91
  "Where you cannot see enough of the code to judge something, say so instead of",
92
92
  "guessing."
93
93
  ].join("\n");
94
- var PLAN_PROMPT = [
95
- "Write a short implementation plan for this issue.",
94
+ var REPLY_PROMPT = [
95
+ "Answer this issue in a comment, the way a teammate who knows this codebase would.",
96
96
  "",
97
- "Say what should change, in which files where you can tell, and what to watch",
98
- "out for. Stay inside what the issue actually supports: if it is too vague to",
99
- "plan, say what is missing rather than inventing the requirements.",
97
+ "Work out what the issue is asking for. If it is a question, answer it from the",
98
+ "code rather than from memory. If it reports a bug, find the likely cause and",
99
+ "point at the files and lines involved. If it asks for a change, say how you",
100
+ "would make it, where, and what to watch out for.",
100
101
  "",
101
- "Do not write the implementation."
102
- ].join("\n");
103
- var IMPLEMENT_PROMPT = [
104
- "Implement this issue in the checkout you are in.",
105
- "",
106
- "Read enough of the surrounding code first that what you write looks like it",
107
- "belongs: the same patterns, the same names, the same way of handling errors.",
108
- "Where the project has tests for work like this, add one.",
109
- "",
110
- "Stay inside what the issue asks for. A change that also tidies three other",
111
- "things is a change nobody can review. If the issue is too vague to implement,",
112
- "or doing it properly needs a decision that is not yours to make, reply with",
113
- "exactly NOTHING_TO_DO rather than guessing: an unwanted pull request costs",
114
- "more attention than a missing one.",
115
- "",
116
- "Run the project's tests if you can work out how, and say in your description",
117
- "whether you did and what happened."
102
+ "Read the code; do not change it. If the issue is too vague to answer, say what",
103
+ "is missing rather than inventing it."
118
104
  ].join("\n");
119
105
  var githubManifest = defineManifest({
120
106
  id: "github",
121
107
  name: "GitHub",
122
- tagline: "Pick up review requests and assigned issues.",
108
+ tagline: "Pick up reviews and issues sent to your team's shared GitHub account.",
123
109
  docsUrl: "https://docs.github.com/rest",
124
110
  icon: "Github",
125
111
  accent: "bg-neutral-900 text-white",
126
112
  auth: {
127
113
  kind: "oauth_redirect",
128
114
  scopes: GITHUB_SCOPES,
129
- needsAppRegistration: true
115
+ needsAppRegistration: true,
116
+ note: "Connect the account the team will send work to. An account made for this is cleanest, but your own works too if you are happy to share it. Reviews and comments appear under its name, and it sees only the repositories it can access. Sign in to GitHub as that account first; GitHub authorizes whichever account the browser is signed in as."
130
117
  },
131
- workflows: [
132
- {
133
- id: "github.review_requested",
134
- name: "Review pull requests I am asked to review",
135
- summary: "Reads the change and posts a review whenever someone asks for yours.",
136
- trigger: "your review is requested, either directly or through a team you belong to",
137
- watches: "is:open is:pr review-requested:@me",
138
- writes: "a review on the pull request, as a comment rather than an approval",
139
- settings: [
140
- repositories,
141
- ignoreDrafts,
142
- ignoreBots,
143
- sizeLimit
144
- ],
145
- prompt: REVIEW_PROMPT,
146
- guidancePlaceholder: "Anything specific to your team. For example: we require a test for every new endpoint.",
147
- answer: "review",
148
- runsIn: "checkout",
149
- actionId: "github.submit_review"
150
- },
151
- {
152
- id: "github.issue_assigned",
153
- name: "Plan issues assigned to me",
154
- summary: "Posts a short implementation plan when an issue lands on you.",
155
- trigger: "an issue is assigned to you",
156
- watches: "is:open is:issue assignee:@me",
157
- writes: "a comment on the issue",
158
- settings: [repositories],
159
- prompt: PLAN_PROMPT,
160
- guidancePlaceholder: "Anything specific to this codebase worth knowing before planning.",
161
- answer: "text",
162
- runsIn: "checkout",
163
- actionId: "github.post_issue_comment"
164
- },
165
- {
166
- id: "github.issue_implement",
167
- name: "Implement issues assigned to me",
168
- summary: "Writes the change and opens a draft pull request.",
169
- trigger: "an issue is assigned to you",
170
- watches: "is:open is:issue assignee:@me",
171
- writes: "a draft pull request",
172
- runsIn: "checkout",
173
- settings: [repositories],
174
- prompt: IMPLEMENT_PROMPT,
175
- guidancePlaceholder: "How work is done here. For example: every new endpoint needs a test, and we do not add dependencies without asking.",
176
- answer: "code",
177
- actionId: "github.open_pull_request"
178
- }
179
- ],
180
- actions: [
181
- {
182
- id: "github.submit_review",
183
- name: "Submit a review",
184
- summary: "Post a review on a pull request, as a comment rather than an approval.",
185
- target: [],
186
- accepts: ["text", "review"]
187
- },
188
- {
189
- id: "github.post_issue_comment",
190
- name: "Comment on an issue",
191
- summary: "Post a comment on an issue or pull request.",
192
- target: [issueTarget],
193
- accepts: ["text"]
194
- },
195
- {
196
- id: "github.open_pull_request",
197
- name: "Open a draft pull request",
198
- summary: "Open a draft pull request for the branch the agent already pushed.",
199
- target: [],
200
- accepts: ["code"]
201
- }
202
- ],
118
+ triggers: [{
119
+ id: "github.review_requested",
120
+ when: "A GitHub review is requested",
121
+ trigger: "someone requests a review from the connected account, directly or through its team",
122
+ watches: "is:open is:pr review-requested:@me",
123
+ settings: [
124
+ repositories,
125
+ ignoreDrafts,
126
+ ignoreBots,
127
+ sizeLimit
128
+ ],
129
+ runsIn: "checkout",
130
+ answer: "review",
131
+ actionId: "github.submit_review"
132
+ }, {
133
+ id: "github.issue_assigned",
134
+ when: "A GitHub issue is assigned",
135
+ trigger: "an issue is assigned to the connected account",
136
+ watches: "is:open is:issue assignee:@me",
137
+ settings: [repositories],
138
+ runsIn: "checkout",
139
+ answer: "text",
140
+ actionId: "github.post_issue_comment"
141
+ }],
142
+ workflows: [{
143
+ id: "github.review_requested",
144
+ name: "Review pull requests",
145
+ summary: "Posts a review when someone requests one from the connected account.",
146
+ triggerId: "github.review_requested",
147
+ prompt: REVIEW_PROMPT
148
+ }, {
149
+ id: "github.issue_assigned",
150
+ name: "Reply to issues",
151
+ summary: "Answers an issue in a comment when it is assigned to the connected account.",
152
+ triggerId: "github.issue_assigned",
153
+ prompt: REPLY_PROMPT
154
+ }],
155
+ actions: [{
156
+ id: "github.submit_review",
157
+ name: "Submit a review",
158
+ summary: "Post a review on a pull request, as a comment rather than an approval.",
159
+ target: [],
160
+ accepts: ["text", "review"]
161
+ }, {
162
+ id: "github.post_issue_comment",
163
+ name: "Comment on an issue",
164
+ summary: "Post a comment on an issue or pull request.",
165
+ target: [issueTarget],
166
+ accepts: ["text"]
167
+ }],
203
168
  settings: [],
204
169
  allowsMultipleAccounts: false,
205
170
  byHand: {
@@ -208,86 +173,14 @@ var githubManifest = defineManifest({
208
173
  }
209
174
  });
210
175
  /**
211
- * Reading is the whole of it. Gmail has no send scope here on purpose: an
212
- * agent that can reply as you, to anyone, is a different and much larger thing
213
- * to agree to than one that reads your inbox and tells you about it.
214
- */
215
- var GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly"];
216
- /**
217
- * Gmail's own search, appended to what the workflow already asks. This is the
218
- * setting that makes the difference between a useful loop and a firehose, so
219
- * it leads.
220
- */
221
- var extraQuery = {
222
- key: "extraQuery",
223
- kind: "text",
224
- label: "Narrow it down",
225
- help: "Gmail search terms, added to the query above. For example: from:bank.example.com, or subject:invoice, or -from:notifications@github.com.",
226
- placeholder: "from:boss@example.com"
227
- };
228
- /**
229
- * A cap on how many mails one look can turn into runs, which is a cost limit
230
- * rather than a filter: every mail past it is still noticed on the next poll,
231
- * because nothing has marked it as dealt with.
232
- */
233
- var perPoll = {
234
- key: "maxPerPoll",
235
- kind: "select",
236
- label: "At most, per look",
237
- help: "Each mail costs a full agent run. A quiet mailbox never reaches this; a busy one should be narrowed above rather than raised here.",
238
- options: [
239
- {
240
- value: "3",
241
- label: "3 messages"
242
- },
243
- {
244
- value: "10",
245
- label: "10 messages"
246
- },
247
- {
248
- value: "25",
249
- label: "25 messages"
250
- }
251
- ],
252
- default: "10"
253
- };
254
- /**
255
- * The setting that decides whether a quiet mailbox is quiet. Without it every
256
- * receipt arrives as a notification saying a receipt arrived, which costs a
257
- * run each and trains you to stop looking.
176
+ * Read, and send. Sending is only ever to the connected account itself, the
177
+ * way a bot answers whoever asked it; the agent never holds the token.
258
178
  */
259
- var needsMeOnly = {
260
- key: "needsMeOnly",
261
- kind: "boolean",
262
- label: "Only tell me when it needs me",
263
- help: "One cheap look at everything that arrived decides which mails are actually asking something of you, from the sender, subject and first line. The rest wait in the backlog with a reason, so you can still run one if this got it wrong.",
264
- default: true
265
- };
266
- var skipOwn = {
267
- key: "skipOwn",
268
- kind: "boolean",
269
- label: "Skip mail I sent",
270
- help: "Your own messages show up in threads you are part of, and being told about what you just sent is not useful.",
271
- default: true
272
- };
273
- var TELL_ME_PROMPT = [
274
- "Read the email in EMAIL.md and tell me what it says.",
275
- "",
276
- "You are writing a phone notification, not a summary document. Two or three",
277
- "sentences. No headings, no bullet lists, no preamble like \"this email is\".",
278
- "Lead with who it is from and what they want.",
279
- "",
280
- "Say plainly if it needs something from me and by when. If there is a",
281
- "deadline, an amount of money, a date, or a link I have to act on, include it",
282
- "exactly as written rather than describing it.",
283
- "",
284
- "If it is an automated notification, a newsletter, or otherwise needs nothing",
285
- "from me, say so in one line and stop."
286
- ].join("\n");
179
+ var GMAIL_SCOPES = ["https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.send"];
287
180
  var gmailManifest = defineManifest({
288
181
  id: "gmail",
289
182
  name: "Gmail",
290
- tagline: "Read new mail and have an agent tell you what matters.",
183
+ tagline: "Start a loop when an email arrives, or email an answer to the connected account.",
291
184
  docsUrl: "https://developers.google.com/gmail/api/guides",
292
185
  icon: "Gmail",
293
186
  accent: "bg-[#ea4335] text-white",
@@ -296,29 +189,67 @@ var gmailManifest = defineManifest({
296
189
  scopes: GMAIL_SCOPES,
297
190
  needsAppRegistration: true
298
191
  },
299
- workflows: [{
192
+ triggers: [{
300
193
  id: "gmail.new_mail",
301
- name: "Tell me about new mail",
302
- summary: "Reads mail as it arrives and sends you a short account of it somewhere you look.",
303
- trigger: "mail arrives in your inbox",
194
+ when: "An email arrives",
195
+ trigger: "an email arrives in the connected inbox",
304
196
  watches: "in:inbox newer_than:1d",
305
- writes: "a message telling you what arrived",
306
197
  runsIn: "temp",
307
198
  settings: [
308
- extraQuery,
309
- needsMeOnly,
310
- perPoll,
311
- skipOwn
199
+ {
200
+ key: "extraQuery",
201
+ kind: "text",
202
+ label: "Only mail matching",
203
+ help: "Gmail search terms, such as from:billing@vendor.com or subject:invoice.",
204
+ placeholder: "from:alerts@example.com"
205
+ },
206
+ {
207
+ key: "needsMeOnly",
208
+ kind: "boolean",
209
+ label: "Skip mail that needs nothing",
210
+ help: "A quick look first decides which mail asks for something. The rest is kept, not run.",
211
+ default: true
212
+ },
213
+ {
214
+ key: "maxPerPoll",
215
+ kind: "select",
216
+ label: "At most, per check",
217
+ options: [
218
+ {
219
+ value: "3",
220
+ label: "3 emails"
221
+ },
222
+ {
223
+ value: "10",
224
+ label: "10 emails"
225
+ },
226
+ {
227
+ value: "25",
228
+ label: "25 emails"
229
+ }
230
+ ],
231
+ default: "10"
232
+ },
233
+ {
234
+ key: "skipOwn",
235
+ kind: "boolean",
236
+ label: "Skip mail sent from this account",
237
+ default: true
238
+ }
312
239
  ],
313
- prompt: TELL_ME_PROMPT,
314
- guidancePlaceholder: "What you care about in your mail. For example: I only need to know about anything involving money or a deadline.",
315
240
  answer: "text",
316
- actionConnectorId: "wechat",
317
- actionId: "wechat.reply",
318
- actionTarget: { to: "me" },
241
+ actionConnectorId: "schedule",
242
+ actionId: "schedule.record",
319
243
  pollEveryMs: 18e5
320
244
  }],
321
- actions: [],
245
+ workflows: [],
246
+ actions: [{
247
+ id: "gmail.send",
248
+ name: "Email this account",
249
+ summary: "Send the answer to the connected account's own inbox, in the same thread when it started from an email there.",
250
+ target: [],
251
+ accepts: ["text"]
252
+ }],
322
253
  settings: [],
323
254
  allowsMultipleAccounts: false,
324
255
  byHand: { kind: "none" }
@@ -454,27 +385,20 @@ var at = {
454
385
  key: "at",
455
386
  kind: "text",
456
387
  label: "At",
457
- help: "On a 24-hour clock, in this host's own time zone. Only the minutes are used by an hourly loop.",
388
+ help: "24-hour time, in this host's time zone.",
458
389
  placeholder: "09:00"
459
390
  };
460
391
  var weekday = {
461
392
  key: "weekday",
462
393
  kind: "select",
463
394
  label: "On",
464
- help: "Only used by a loop that runs once a week.",
395
+ help: "For weekly loops.",
465
396
  options: WEEKDAY_NAMES.map((name, index) => ({
466
397
  value: String(index),
467
398
  label: name
468
399
  })),
469
400
  default: "1"
470
401
  };
471
- var folder$1 = {
472
- key: "folder",
473
- kind: "text",
474
- label: "Folder to work in",
475
- help: "An absolute path. Nothing arrives to be looked at on a schedule, so this is the whole of what the agent is given.",
476
- placeholder: "/Users/you/code/web"
477
- };
478
402
  /**
479
403
  * Deliberately a frame rather than a job. Every other workflow ships a prompt
480
404
  * because "review a pull request" is a problem worth solving once and getting
@@ -504,44 +428,175 @@ var scheduleManifest = defineManifest({
504
428
  allowsMultipleAccounts: false,
505
429
  byHand: { kind: "now" },
506
430
  settings: [],
507
- workflows: [{
431
+ triggers: [{
508
432
  id: "schedule.recurring",
509
- name: "Do something on a schedule",
510
- summary: "Runs an agent in a folder you name, and keeps what it says.",
433
+ when: "On a schedule",
511
434
  trigger: "the time you set comes round",
512
- writes: "a note in the log",
513
435
  runsIn: "folder",
514
436
  settings: [
515
437
  every,
516
438
  at,
517
- weekday,
518
- folder$1
439
+ weekday
519
440
  ],
520
- prompt: RECURRING_PROMPT,
521
- guidancePlaceholder: "Anything that holds every time this runs. For example: this repository releases on Thursdays, so say if anything is unmerged by Wednesday evening.",
522
441
  answer: "text",
523
442
  actionId: "schedule.record"
524
443
  }],
444
+ workflows: [{
445
+ id: "schedule.recurring",
446
+ name: "Do something on a schedule",
447
+ summary: "Runs the agent at the times you set, in a folder you name. The answer stays in Inbox, or goes to Slack, Lark, or GitHub.",
448
+ triggerId: "schedule.recurring",
449
+ prompt: RECURRING_PROMPT
450
+ }],
525
451
  actions: [{
526
452
  id: "schedule.record",
527
- name: "Keep it in the log",
528
- summary: "Leaves the answer here and sends it nowhere. Every run is recorded anyway, so this is what to use until it is worth pushing somewhere.",
453
+ name: "Keep in Inbox",
454
+ summary: "Keeps the answer in Inbox and sends it nowhere.",
529
455
  target: [],
530
456
  accepts: ["text", "review"]
531
457
  }]
532
458
  });
533
- /**
534
- * Where the agent runs. Asking a phone "what broke the build" is only worth
535
- * anything if the agent can read the build, so this workflow works in a real
536
- * checkout rather than an empty directory.
537
- */
538
- var folder = {
539
- key: "folder",
540
- kind: "text",
541
- label: "Folder to work in",
542
- help: "An absolute path to a checkout on this host. The agent runs here, so it can read the code you are asking about.",
543
- placeholder: "/Users/you/code/your-project"
544
- };
459
+ var slackManifest = defineManifest({
460
+ id: "slack",
461
+ name: "Slack bot",
462
+ tagline: "Paste a bot token from a Slack app — not a Slack login. @mention or DM the bot, and it answers in the thread.",
463
+ docsUrl: "https://api.slack.com/apps",
464
+ icon: "Slack",
465
+ accent: "bg-[#4a154b] text-white",
466
+ auth: {
467
+ kind: "token",
468
+ helpUrl: "https://api.slack.com/apps",
469
+ note: "This is a Slack app in the workspace, not a Slack account sign-in. Create an app at api.slack.com/apps, add bot scopes (channels:history, channels:read, groups:history, groups:read, im:history, im:read, mpim:history, mpim:read, chat:write), Install to Workspace, then paste the Bot User OAuth Token. Invite the bot to any channel it should watch.",
470
+ fields: [{
471
+ key: "botToken",
472
+ label: "Bot User OAuth Token",
473
+ placeholder: "xoxb-...",
474
+ secret: true
475
+ }]
476
+ },
477
+ triggers: [{
478
+ id: "slack.ask",
479
+ when: "The Slack bot is mentioned",
480
+ trigger: "someone @mentions the Slack bot in a channel it is in, or DMs it",
481
+ runsIn: "folder",
482
+ settings: [],
483
+ answer: "text",
484
+ actionId: "slack.reply",
485
+ pollEveryMs: 6e4
486
+ }],
487
+ workflows: [{
488
+ id: "slack.ask",
489
+ name: "Do what I ask the Slack bot",
490
+ summary: "Runs the agent when someone @mentions the Slack bot or DMs it, and answers in the thread.",
491
+ triggerId: "slack.ask",
492
+ prompt: [
493
+ "Someone has sent you a message in Slack. Do what it asks, working in this directory,",
494
+ "and then answer them.",
495
+ "",
496
+ "You are answering into Slack. Be brief: a couple of short paragraphs at most,",
497
+ "no headings, no bullet lists unless you are genuinely listing things. Plain sentences.",
498
+ "",
499
+ "If the message asks a question about this code, read enough of it to answer",
500
+ "properly rather than guessing. If it asks for a change, make the change and",
501
+ "say in one line what you did. If the message is too vague to act on, say what",
502
+ "you need to know instead of assuming."
503
+ ].join("\n")
504
+ }],
505
+ actions: [{
506
+ id: "slack.reply",
507
+ name: "Reply in Slack",
508
+ summary: "Post a message back to the thread that asked, or to a channel you name.",
509
+ target: [{
510
+ key: "channel",
511
+ kind: "text",
512
+ label: "Channel",
513
+ help: "Empty replies where it was asked. Or a channel ID.",
514
+ placeholder: "C0123456789"
515
+ }],
516
+ accepts: ["text"]
517
+ }],
518
+ settings: [],
519
+ allowsMultipleAccounts: false,
520
+ byHand: { kind: "none" }
521
+ });
522
+ var feishuManifest = defineManifest({
523
+ id: "feishu",
524
+ name: "Feishu / Lark bot",
525
+ tagline: "Paste App ID and App Secret from a Feishu or Lark app — not a Feishu login. @mention the bot in a group, and it answers in the thread.",
526
+ docsUrl: "https://open.feishu.cn/app",
527
+ icon: "Feishu",
528
+ accent: "bg-[#3370ff] text-white",
529
+ auth: {
530
+ kind: "token",
531
+ helpUrl: "https://open.feishu.cn/app",
532
+ note: "This is a custom Feishu or Lark app, not a Feishu account sign-in. Create an app, enable Bot, add permissions (im:chat:readonly, im:message, im:message.group_msg, im:message:send_as_bot), create a version and publish it, then paste App ID and App Secret. Invite the bot to any group it should watch. Put lark in Open platform for Lark (international).",
533
+ fields: [
534
+ {
535
+ key: "appId",
536
+ label: "App ID",
537
+ placeholder: "cli_...",
538
+ secret: false
539
+ },
540
+ {
541
+ key: "appSecret",
542
+ label: "App Secret",
543
+ placeholder: "App Secret",
544
+ secret: true
545
+ },
546
+ {
547
+ key: "host",
548
+ label: "Open platform",
549
+ placeholder: "feishu or lark",
550
+ secret: false,
551
+ optional: true
552
+ }
553
+ ]
554
+ },
555
+ triggers: [{
556
+ id: "feishu.ask",
557
+ when: "The Feishu / Lark bot is mentioned",
558
+ trigger: "someone @mentions the Feishu / Lark bot in a group it is in",
559
+ runsIn: "folder",
560
+ settings: [],
561
+ answer: "text",
562
+ actionId: "feishu.reply",
563
+ pollEveryMs: 6e4
564
+ }],
565
+ workflows: [{
566
+ id: "feishu.ask",
567
+ name: "Do what I ask the Feishu / Lark bot",
568
+ summary: "Runs the agent when someone @mentions the Feishu / Lark bot in a group, and answers in the thread.",
569
+ triggerId: "feishu.ask",
570
+ prompt: [
571
+ "Someone has sent you a message in Feishu. Do what it asks, working in this directory,",
572
+ "and then answer them.",
573
+ "",
574
+ "You are answering into Feishu. Be brief: a couple of short paragraphs at most,",
575
+ "no headings, no bullet lists unless you are genuinely listing things. Plain sentences.",
576
+ "",
577
+ "If the message asks a question about this code, read enough of it to answer",
578
+ "properly rather than guessing. If it asks for a change, make the change and",
579
+ "say in one line what you did. If the message is too vague to act on, say what",
580
+ "you need to know instead of assuming."
581
+ ].join("\n")
582
+ }],
583
+ actions: [{
584
+ id: "feishu.reply",
585
+ name: "Reply in Feishu / Lark",
586
+ summary: "Post a message back to the thread that asked, or to a chat you name.",
587
+ target: [{
588
+ key: "chat",
589
+ kind: "text",
590
+ label: "Chat ID",
591
+ help: "Empty replies where it was asked. Or a chat ID (oc_…).",
592
+ placeholder: "oc_..."
593
+ }],
594
+ accepts: ["text"]
595
+ }],
596
+ settings: [],
597
+ allowsMultipleAccounts: false,
598
+ byHand: { kind: "none" }
599
+ });
545
600
  /**
546
601
  * A bot bound to your account can be messaged by other people, and this
547
602
  * workflow runs a coding agent on a runner. Answering only yourself is the
@@ -571,7 +626,7 @@ var recipient = {
571
626
  key: "to",
572
627
  kind: "select",
573
628
  label: "Who to send it to",
574
- help: "WeChat only reliably delivers a reply to a conversation you started. Sending to yourself out of the blue is refused unless you have written to the bot in the last half day or so, which makes this a poor fit for anything that has to arrive overnight.",
629
+ help: "\"Me\" only arrives if you messaged the bot in the last half day.",
575
630
  options: [{
576
631
  value: "source",
577
632
  label: "Whoever asked"
@@ -600,6 +655,8 @@ var ASK_PROMPT = [
600
655
  */
601
656
  var CONNECTOR_MANIFESTS = [
602
657
  githubManifest,
658
+ slackManifest,
659
+ feishuManifest,
603
660
  gmailManifest,
604
661
  {
605
662
  id: "wechat",
@@ -612,19 +669,22 @@ var CONNECTOR_MANIFESTS = [
612
669
  kind: "qr_scan",
613
670
  note: "Scanning binds a bot to your WeChat account. Loopable can then read what people send that bot and answer as it, and nothing else on your account."
614
671
  },
615
- workflows: [{
672
+ triggers: [{
616
673
  id: "wechat.ask",
617
- name: "Do what I ask the bot",
618
- summary: "Runs your agent on a runner when you message the bot, and answers in the chat.",
619
- trigger: "you send the bot a message",
620
- writes: "a reply in the chat",
674
+ when: "The WeChat bot gets a message",
675
+ trigger: "someone sends the WeChat bot a message",
621
676
  runsIn: "folder",
622
- settings: [folder, askers],
623
- prompt: ASK_PROMPT,
624
- guidancePlaceholder: "Anything the agent should know about this project before it starts. For example: run pnpm test before saying a change works.",
677
+ settings: [askers],
625
678
  answer: "text",
626
679
  actionId: "wechat.reply"
627
680
  }],
681
+ workflows: [{
682
+ id: "wechat.ask",
683
+ name: "Do what I ask the WeChat bot",
684
+ summary: "Runs the agent when someone messages the WeChat bot, and answers in the chat.",
685
+ triggerId: "wechat.ask",
686
+ prompt: ASK_PROMPT
687
+ }],
628
688
  actions: [{
629
689
  id: "wechat.reply",
630
690
  name: "Reply in the chat",
@@ -642,26 +702,106 @@ function connectorManifest(id) {
642
702
  return CONNECTOR_MANIFESTS.find((manifest) => manifest.id === id);
643
703
  }
644
704
  /**
645
- * Whether this connector is somewhere you have an account.
705
+ * Whether this connector is somewhere you have a Connection.
646
706
  *
647
707
  * Asked in three places that would otherwise each assume every connector is:
648
- * the page listing accounts, the check for what stands between a loop and
708
+ * the page listing connections, the check for what stands between a loop and
649
709
  * running, and the fetch of a credential for a run.
650
710
  */
651
711
  function needsAccount(id) {
652
712
  return connectorManifest(id)?.auth.kind !== "none";
653
713
  }
714
+ /**
715
+ * What one Connection of this connector is called in the UI.
716
+ * A Slack or Feishu bot is a pasted app credential, not a login.
717
+ */
718
+ function connectionNoun(id) {
719
+ return connectorManifest(id)?.auth.kind === "token" ? "bot" : "account";
720
+ }
721
+ /** A trigger with nothing on top: what a custom loop runs. */
722
+ function customOn(trigger) {
723
+ return {
724
+ ...trigger,
725
+ triggerId: trigger.id,
726
+ name: trigger.when,
727
+ summary: `When ${trigger.trigger}.`,
728
+ prompt: "",
729
+ custom: true
730
+ };
731
+ }
732
+ /**
733
+ * What a loop runs, by the id it stores: a workflow, merged with the trigger
734
+ * it sits on, or a trigger alone for a custom loop.
735
+ */
654
736
  function connectorWorkflow(connectorId, workflowId) {
655
- return connectorManifest(connectorId)?.workflows.find((entry) => entry.id === workflowId);
737
+ const manifest = connectorManifest(connectorId);
738
+ if (!manifest) return void 0;
739
+ const defined = manifest.workflows.find((entry) => entry.id === workflowId);
740
+ if (defined) {
741
+ const trigger = manifest.triggers.find((entry) => entry.id === defined.triggerId);
742
+ if (!trigger) return void 0;
743
+ return {
744
+ ...trigger,
745
+ ...defined,
746
+ actionId: defined.actionId ?? trigger.actionId,
747
+ actionConnectorId: defined.actionConnectorId ?? trigger.actionConnectorId,
748
+ actionTarget: defined.actionTarget ?? trigger.actionTarget,
749
+ pollEveryMs: defined.pollEveryMs ?? trigger.pollEveryMs,
750
+ triggerId: trigger.id,
751
+ custom: false
752
+ };
753
+ }
754
+ const trigger = manifest.triggers.find((entry) => entry.id === workflowId);
755
+ return trigger ? customOn(trigger) : void 0;
656
756
  }
657
757
  function connectorAction(connectorId, actionId) {
658
758
  return connectorManifest(connectorId)?.actions.find((entry) => entry.id === actionId);
659
759
  }
660
- /** Every workflow on offer, for the page that asks which one to turn on. */
760
+ /**
761
+ * Keeping the answer in Inbox is the clock's action, because the clock is the
762
+ * one connector with nowhere of its own to write. To a person it is not a
763
+ * Schedule thing at all, so it is named for where the answer stays.
764
+ */
765
+ var INBOX_ONLY = {
766
+ connectorId: "schedule",
767
+ actionId: "schedule.record"
768
+ };
769
+ function isInboxOnly(connectorId, actionId) {
770
+ return connectorId === INBOX_ONLY.connectorId && actionId === INBOX_ONLY.actionId;
771
+ }
772
+ /** What to call a connector as a place answers go. */
773
+ function writerName(connectorId) {
774
+ if (connectorId === INBOX_ONLY.connectorId) return "Inbox";
775
+ return connectorManifest(connectorId)?.name ?? connectorId;
776
+ }
777
+ /** Where a loop's answer goes, in a few words: "GitHub · Submit a review", or "Inbox only". */
778
+ function destinationLabel(connectorId, actionId) {
779
+ if (isInboxOnly(connectorId, actionId)) return "Inbox only";
780
+ const action = connectorAction(connectorId, actionId);
781
+ return action ? `${writerName(connectorId)} · ${action.name}` : "No destination";
782
+ }
783
+ /** Connectors that can write, Inbox first, for the question of where an answer goes. */
784
+ function writers() {
785
+ const all = CONNECTOR_MANIFESTS.filter((entry) => entry.actions.length > 0);
786
+ return [...all.filter((entry) => entry.id === INBOX_ONLY.connectorId), ...all.filter((entry) => entry.id !== INBOX_ONLY.connectorId)];
787
+ }
788
+ /**
789
+ * Every workflow on offer, for the page that asks which one to turn on.
790
+ *
791
+ * The clock leads: it needs no account, and a scheduled job is the easiest
792
+ * first loop to try. The rest follow the connectors' own order.
793
+ */
661
794
  function allWorkflows() {
662
- return CONNECTOR_MANIFESTS.flatMap((connector) => connector.workflows.map((workflow) => ({
795
+ return [...CONNECTOR_MANIFESTS.filter((connector) => connector.id === "schedule"), ...CONNECTOR_MANIFESTS.filter((connector) => connector.id !== "schedule")].flatMap((connector) => connector.workflows.map((entry) => ({
796
+ connector,
797
+ workflow: connectorWorkflow(connector.id, entry.id)
798
+ })));
799
+ }
800
+ /** Every trigger on offer, the clock first, for choosing what starts a custom loop. */
801
+ function allTriggers() {
802
+ return [...CONNECTOR_MANIFESTS.filter((connector) => connector.id === "schedule"), ...CONNECTOR_MANIFESTS.filter((connector) => connector.id !== "schedule")].flatMap((connector) => connector.triggers.map((trigger) => ({
663
803
  connector,
664
- workflow
804
+ trigger
665
805
  })));
666
806
  }
667
807
  /**
@@ -1124,15 +1264,15 @@ function candidates(items, repositories) {
1124
1264
  });
1125
1265
  }
1126
1266
  /**
1127
- * What the workflow says it watches for, which is what gets asked. Taking the
1267
+ * What the trigger says it watches for, which is what gets asked. Taking the
1128
1268
  * query from the manifest is the point: it is shown on the loop page, and a
1129
1269
  * search that quietly differed from the one on screen would be the worst kind
1130
1270
  * of wrong.
1131
1271
  */
1132
- function query(workflowId) {
1133
- const workflow = githubManifest.workflows.find((entry) => entry.id === workflowId);
1134
- if (!workflow?.watches) throw new Error(`GitHub cannot watch for ${workflowId}.`);
1135
- return workflow.watches;
1272
+ function query(triggerId) {
1273
+ const trigger = githubManifest.triggers.find((entry) => entry.id === triggerId);
1274
+ if (!trigger?.watches) throw new Error(`GitHub cannot watch for ${triggerId}.`);
1275
+ return trigger.watches;
1136
1276
  }
1137
1277
  async function pollGithub(input) {
1138
1278
  const repositories = stringList(input.settings.repositories);
@@ -1405,13 +1545,18 @@ var githubRuntime = defineRuntime({
1405
1545
  }
1406
1546
  });
1407
1547
  var BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
1408
- async function request(accessToken, path) {
1548
+ async function request(accessToken, path, init = {}) {
1409
1549
  let res;
1410
1550
  try {
1411
- res = await fetch(`${BASE}${path}`, { headers: {
1412
- Authorization: `Bearer ${accessToken}`,
1413
- Accept: "application/json"
1414
- } });
1551
+ res = await fetch(`${BASE}${path}`, {
1552
+ method: init.method ?? "GET",
1553
+ headers: {
1554
+ Authorization: `Bearer ${accessToken}`,
1555
+ Accept: "application/json",
1556
+ ...init.body === void 0 ? {} : { "Content-Type": "application/json" }
1557
+ },
1558
+ ...init.body === void 0 ? {} : { body: JSON.stringify(init.body) }
1559
+ });
1415
1560
  } catch (error) {
1416
1561
  throw new TransientError(`Gmail could not be reached: ${error instanceof Error ? error.message : String(error)}`);
1417
1562
  }
@@ -1420,6 +1565,7 @@ async function request(accessToken, path) {
1420
1565
  const message = `Gmail ${res.status} on ${path}: ${detail}`;
1421
1566
  const backoff = res.status === 403 && /rateLimitExceeded|userRateLimitExceeded/i.test(detail);
1422
1567
  if (res.status === 429 || res.status >= 500 || backoff) throw new TransientError(message);
1568
+ if (res.status === 403 && /insufficient.*scope|ACCESS_TOKEN_SCOPE_INSUFFICIENT/i.test(detail)) throw new Error("This Gmail connection cannot send mail. Reconnect Gmail to allow it.");
1423
1569
  throw new Error(message);
1424
1570
  }
1425
1571
  return await res.json();
@@ -1456,6 +1602,16 @@ function getMessageHeaders(accessToken, id) {
1456
1602
  function getMessage(accessToken, id) {
1457
1603
  return request(accessToken, `/messages/${id}?format=full`);
1458
1604
  }
1605
+ /** Send a message already written as RFC 2822 text, in a thread when one is given. */
1606
+ function sendMessage$2(accessToken, raw, threadId) {
1607
+ return request(accessToken, "/messages/send", {
1608
+ method: "POST",
1609
+ body: threadId ? {
1610
+ raw,
1611
+ threadId
1612
+ } : { raw }
1613
+ });
1614
+ }
1459
1615
  /** A Gmail message id, as a ref: short, stable, and unique to the mailbox. */
1460
1616
  function mailRef(id) {
1461
1617
  return `mail#${id}`;
@@ -1464,7 +1620,7 @@ function mailRef(id) {
1464
1620
  * Where to send someone who wants to see the mail itself. Gmail has no
1465
1621
  * per-message API link, but the web client will open one by id.
1466
1622
  */
1467
- function permalink(id) {
1623
+ function permalink$2(id) {
1468
1624
  return `https://mail.google.com/mail/u/0/#all/${id}`;
1469
1625
  }
1470
1626
  function headerOf(message, name) {
@@ -1532,7 +1688,7 @@ function attachmentNames(message) {
1532
1688
  return names;
1533
1689
  }
1534
1690
  /** Enough of a message to pick it out of a list. */
1535
- function summarise$1(message) {
1691
+ function summarise$3(message) {
1536
1692
  const subject = headerOf(message, "Subject") || "(no subject)";
1537
1693
  const who = senderName(headerOf(message, "From"));
1538
1694
  const title = who ? `${who}: ${subject}` : subject;
@@ -1567,6 +1723,30 @@ function mailContext(message) {
1567
1723
  ].join("\n")
1568
1724
  };
1569
1725
  }
1726
+ /** Base64url, as Gmail wants a raw message. */
1727
+ function base64url(text) {
1728
+ return Buffer.from(text, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
1729
+ }
1730
+ /** A header value that may hold any text, encoded so non-ASCII survives. */
1731
+ function encodedWord(text) {
1732
+ return /^[\x20-\x7e]*$/.test(text) ? text : `=?UTF-8?B?${Buffer.from(text, "utf8").toString("base64")}?=`;
1733
+ }
1734
+ /**
1735
+ * A plain-text email as Gmail's `raw`. The body is base64 so any line length
1736
+ * and any language go through untouched.
1737
+ */
1738
+ function composeMail(input) {
1739
+ return base64url([
1740
+ `To: ${input.to}`,
1741
+ `Subject: ${encodedWord(input.subject)}`,
1742
+ ...input.inReplyTo ? [`In-Reply-To: ${input.inReplyTo}`, `References: ${input.inReplyTo}`] : [],
1743
+ "MIME-Version: 1.0",
1744
+ "Content-Type: text/plain; charset=\"UTF-8\"",
1745
+ "Content-Transfer-Encoding: base64",
1746
+ "",
1747
+ Buffer.from(input.body, "utf8").toString("base64").replace(/.{76}/g, "$&\r\n")
1748
+ ].join("\r\n"));
1749
+ }
1570
1750
  /**
1571
1751
  * What the workflow says it watches for, which is what gets asked. Taken from
1572
1752
  * the manifest rather than written again here, because it is shown on the loop
@@ -1574,9 +1754,9 @@ function mailContext(message) {
1574
1754
  * worst kind of wrong.
1575
1755
  */
1576
1756
  function baseQuery(workflowId) {
1577
- const workflow = gmailManifest.workflows.find((entry) => entry.id === workflowId);
1578
- if (!workflow?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
1579
- return workflow.watches;
1757
+ const trigger = gmailManifest.triggers.find((entry) => entry.id === workflowId);
1758
+ if (!trigger?.watches) throw new Error(`Gmail cannot watch for ${workflowId}.`);
1759
+ return trigger.watches;
1580
1760
  }
1581
1761
  /**
1582
1762
  * Missing means "the workflow's default", not "off". A loop saved before a
@@ -1611,8 +1791,8 @@ async function pollGmail(input) {
1611
1791
  key: mailRef(id),
1612
1792
  kind: "email",
1613
1793
  ref: mailRef(id),
1614
- title: summarise$1(message),
1615
- url: permalink(id),
1794
+ title: summarise$3(message),
1795
+ url: permalink$2(id),
1616
1796
  preview: (message.snippet ?? "").trim() || void 0,
1617
1797
  payload: { messageId: id }
1618
1798
  });
@@ -1737,14 +1917,14 @@ async function usableToken(credential) {
1737
1917
  };
1738
1918
  }
1739
1919
  /** The mailbox, as a person would recognise it. */
1740
- function accountOf$1(emailAddress) {
1920
+ function accountOf$3(emailAddress) {
1741
1921
  return {
1742
1922
  id: emailAddress,
1743
1923
  label: emailAddress
1744
1924
  };
1745
1925
  }
1746
1926
  /**
1747
- * Which message this task is about. The poll keeps the id, and a pasted link
1927
+ * Which message this task is about. A task keeps the id, and a pasted link
1748
1928
  * carries one too, so either is enough to fetch the mail again.
1749
1929
  */
1750
1930
  function messageIdFrom(url, payload) {
@@ -1778,8 +1958,8 @@ var gmailRuntime = defineRuntime({
1778
1958
  return {
1779
1959
  kind: "email",
1780
1960
  ref: mailRef(message.id),
1781
- title: summarise$1(message),
1782
- url: permalink(message.id),
1961
+ title: summarise$3(message),
1962
+ url: permalink$2(message.id),
1783
1963
  context: [mailContext(message)]
1784
1964
  };
1785
1965
  },
@@ -1792,6 +1972,33 @@ var gmailRuntime = defineRuntime({
1792
1972
  emailAddress: (await getProfile(accessToken)).emailAddress
1793
1973
  }) };
1794
1974
  },
1975
+ /**
1976
+ * Answers go to the connected account itself, the way a bot answers
1977
+ * whoever asked it. When the run started from an email in this inbox, the
1978
+ * answer joins that thread.
1979
+ */
1980
+ async applyAction({ actionId, source, body, credential }) {
1981
+ if (actionId !== "gmail.send") throw new Error(`Gmail cannot ${actionId}.`);
1982
+ const { accessToken } = await usableToken(credential);
1983
+ const profile = await getProfile(accessToken);
1984
+ let subject = source.title || "From Loopable";
1985
+ let threadId;
1986
+ let inReplyTo;
1987
+ const sourceId = source.connectorId === "gmail" ? source.ref.replace(/^mail#/, "") : "";
1988
+ if (sourceId) {
1989
+ const original = await getMessage(accessToken, sourceId);
1990
+ threadId = original.threadId;
1991
+ inReplyTo = headerOf(original, "Message-ID") || void 0;
1992
+ const said = headerOf(original, "Subject");
1993
+ if (said) subject = /^re:/i.test(said) ? said : `Re: ${said}`;
1994
+ }
1995
+ return { url: permalink$2((await sendMessage$2(accessToken, composeMail({
1996
+ to: profile.emailAddress,
1997
+ subject,
1998
+ body,
1999
+ inReplyTo
2000
+ }), threadId)).id) };
2001
+ },
1795
2002
  auth: {
1796
2003
  async startAuthorization({ redirectUri, state }) {
1797
2004
  const registration = requireRegistration();
@@ -1817,12 +2024,12 @@ var gmailRuntime = defineRuntime({
1817
2024
  });
1818
2025
  return {
1819
2026
  credential,
1820
- account: accountOf$1((await getProfile(credential.accessToken)).emailAddress)
2027
+ account: accountOf$3((await getProfile(credential.accessToken)).emailAddress)
1821
2028
  };
1822
2029
  },
1823
2030
  async identity(credential) {
1824
2031
  const { accessToken, refreshed } = await usableToken(credential);
1825
- const account = accountOf$1((await getProfile(accessToken)).emailAddress);
2032
+ const account = accountOf$3((await getProfile(accessToken)).emailAddress);
1826
2033
  return refreshed ? {
1827
2034
  account,
1828
2035
  renewedCredential: refreshed
@@ -1930,6 +2137,643 @@ var scheduleRuntime = {
1930
2137
  }
1931
2138
  };
1932
2139
  /**
2140
+ * Slack Web API, called with a bot token pasted from a workspace-installed app.
2141
+ * Loopable polls; Slack never has to reach this machine.
2142
+ */
2143
+ var API$1 = "https://slack.com/api";
2144
+ async function slack(path, token, init = {}) {
2145
+ const url = new URL(`${API$1}/${path}`);
2146
+ for (const [key, value] of Object.entries(init.query ?? {})) url.searchParams.set(key, value);
2147
+ let response;
2148
+ try {
2149
+ response = await fetch(url.href, {
2150
+ method: init.method ?? "GET",
2151
+ headers: {
2152
+ Authorization: `Bearer ${token}`,
2153
+ ...init.body ? { "Content-Type": "application/json" } : {}
2154
+ },
2155
+ body: init.body ? JSON.stringify(init.body) : void 0
2156
+ });
2157
+ } catch (error) {
2158
+ throw new TransientError(`Slack is unreachable: ${error.message}`);
2159
+ }
2160
+ if (response.status >= 500 || response.status === 429) throw new TransientError(`Slack answered ${response.status}`);
2161
+ if (!response.ok) throw new Error(`Slack answered ${response.status}`);
2162
+ return await response.json();
2163
+ }
2164
+ function refuse$1(error) {
2165
+ if (error === "invalid_auth" || error === "not_authed" || error === "token_revoked" || error === "account_inactive") throw new Error("Slack did not accept that token.");
2166
+ throw new Error(error ? `Slack refused: ${error}` : "Slack refused the request.");
2167
+ }
2168
+ async function authTest(botToken) {
2169
+ const body = await slack("auth.test", botToken);
2170
+ if (!body.ok) refuse$1(body.error);
2171
+ const teamUrl = typeof body.url === "string" ? body.url : "";
2172
+ const team = typeof body.team === "string" ? body.team : "";
2173
+ const teamId = typeof body.team_id === "string" ? body.team_id : "";
2174
+ const botUserId = typeof body.user_id === "string" ? body.user_id : "";
2175
+ const botId = typeof body.bot_id === "string" ? body.bot_id : "";
2176
+ const botName = typeof body.user === "string" ? body.user : "";
2177
+ if (!teamId || !botUserId) throw new Error("Slack did not return which workspace this bot belongs to.");
2178
+ return {
2179
+ botToken,
2180
+ teamId,
2181
+ team,
2182
+ teamUrl,
2183
+ botUserId,
2184
+ botId,
2185
+ botName
2186
+ };
2187
+ }
2188
+ async function listConversations(credential) {
2189
+ const body = await slack("conversations.list", credential.botToken, { query: {
2190
+ types: "public_channel,private_channel,im,mpim",
2191
+ exclude_archived: "true",
2192
+ limit: "200"
2193
+ } });
2194
+ if (!body.ok) refuse$1(body.error);
2195
+ return (Array.isArray(body.channels) ? body.channels : []).map((raw) => {
2196
+ const channel = raw;
2197
+ if (!channel.id) return null;
2198
+ return {
2199
+ id: channel.id,
2200
+ name: channel.name ?? "",
2201
+ isIm: channel.is_im === true
2202
+ };
2203
+ }).filter((channel) => channel !== null);
2204
+ }
2205
+ async function history$1(credential, channelId, oldest) {
2206
+ const query = {
2207
+ channel: channelId,
2208
+ limit: "100",
2209
+ inclusive: "false"
2210
+ };
2211
+ if (oldest) query.oldest = oldest;
2212
+ const body = await slack("conversations.history", credential.botToken, { query });
2213
+ if (!body.ok) {
2214
+ if (body.error === "not_in_channel" || body.error === "channel_not_found") return [];
2215
+ refuse$1(body.error);
2216
+ }
2217
+ const messages = Array.isArray(body.messages) ? body.messages : [];
2218
+ const parsed = [];
2219
+ for (const raw of messages) {
2220
+ const message = raw;
2221
+ if (!message.ts) continue;
2222
+ parsed.push({
2223
+ ts: message.ts,
2224
+ ...message.user ? { user: message.user } : {},
2225
+ ...message.bot_id ? { botId: message.bot_id } : {},
2226
+ text: message.text ?? "",
2227
+ ...message.thread_ts ? { threadTs: message.thread_ts } : {}
2228
+ });
2229
+ }
2230
+ return parsed.sort((a, b) => Number(a.ts) - Number(b.ts));
2231
+ }
2232
+ async function postMessage(credential, input) {
2233
+ const body = await slack("chat.postMessage", credential.botToken, {
2234
+ method: "POST",
2235
+ body: {
2236
+ channel: input.channel,
2237
+ text: input.text,
2238
+ ...input.threadTs ? { thread_ts: input.threadTs } : {}
2239
+ }
2240
+ });
2241
+ if (!body.ok) refuse$1(body.error);
2242
+ const ts = typeof body.ts === "string" ? body.ts : "";
2243
+ const channel = typeof body.channel === "string" ? body.channel : input.channel;
2244
+ if (!ts) throw new Error("Slack accepted the message but did not say where it landed.");
2245
+ return {
2246
+ ts,
2247
+ channel
2248
+ };
2249
+ }
2250
+ function permalink$1(credential, channel, ts) {
2251
+ return `${credential.teamUrl.replace(/\/$/, "")}/archives/${channel}/p${ts.replace(".", "")}`;
2252
+ }
2253
+ function asAsked$2(value) {
2254
+ const record = value;
2255
+ if (!record || typeof record.channel !== "string" || typeof record.ts !== "string") throw new Error("This task has lost the message it was about.");
2256
+ return {
2257
+ channel: record.channel,
2258
+ ts: record.ts,
2259
+ user: typeof record.user === "string" ? record.user : void 0,
2260
+ text: typeof record.text === "string" ? record.text : "",
2261
+ threadTs: typeof record.threadTs === "string" ? record.threadTs : record.ts
2262
+ };
2263
+ }
2264
+ function asCursor$1(value) {
2265
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
2266
+ const out = {};
2267
+ for (const [key, entry] of Object.entries(value)) if (typeof entry === "string") out[key] = entry;
2268
+ return out;
2269
+ }
2270
+ function summarise$2(text) {
2271
+ const line = text.replace(/<@[^>]+>/g, "").replace(/\s+/g, " ").trim();
2272
+ if (!line) return "Slack message";
2273
+ return line.length > 80 ? `${line.slice(0, 79)}…` : line;
2274
+ }
2275
+ function isAsk$1(input) {
2276
+ if (input.user === input.botUserId || input.messageBotId && input.messageBotId === input.botId) return false;
2277
+ if (input.isIm) return Boolean(input.text.trim());
2278
+ return input.text.includes(`<@${input.botUserId}>`);
2279
+ }
2280
+ function destination$1(target, source) {
2281
+ if (typeof target.channel === "string" && target.channel.trim()) return { channel: target.channel.trim() };
2282
+ if (source.connectorId !== "slack") throw new Error("There is no Slack channel behind this task. Choose one on the loop.");
2283
+ const carry = source.carry ?? {};
2284
+ if (!carry.channel) throw new Error("There is no Slack channel behind this task. Choose one on the loop.");
2285
+ return {
2286
+ channel: carry.channel,
2287
+ threadTs: carry.ts
2288
+ };
2289
+ }
2290
+ function accountOf$2(credential) {
2291
+ return {
2292
+ id: credential.teamId,
2293
+ label: [credential.botName ? `@${credential.botName}` : null, credential.team || credential.teamId].filter(Boolean).join(" · "),
2294
+ url: credential.teamUrl || void 0
2295
+ };
2296
+ }
2297
+ var slackRuntime = {
2298
+ async readiness() {
2299
+ return { ready: true };
2300
+ },
2301
+ async poll({ workflowId, credential, cursor }) {
2302
+ if (workflowId !== "slack.ask") throw new Error(`Slack cannot watch for ${workflowId}.`);
2303
+ const account = credential;
2304
+ const previous = asCursor$1(cursor);
2305
+ const channels = await listConversations(account);
2306
+ const next = { ...previous };
2307
+ const signals = [];
2308
+ for (const channel of channels) {
2309
+ const oldest = previous[channel.id];
2310
+ const messages = await history$1(account, channel.id, oldest);
2311
+ if (messages.length === 0) {
2312
+ if (!next[channel.id]) next[channel.id] = String(Date.now() / 1e3);
2313
+ continue;
2314
+ }
2315
+ next[channel.id] = messages[messages.length - 1].ts;
2316
+ if (!oldest) continue;
2317
+ for (const message of messages) {
2318
+ if (!isAsk$1({
2319
+ text: message.text,
2320
+ user: message.user,
2321
+ messageBotId: message.botId,
2322
+ isIm: channel.isIm,
2323
+ botUserId: account.botUserId,
2324
+ botId: account.botId
2325
+ })) continue;
2326
+ const asked = {
2327
+ channel: channel.id,
2328
+ ts: message.ts,
2329
+ user: message.user,
2330
+ text: message.text,
2331
+ threadTs: message.threadTs ?? message.ts
2332
+ };
2333
+ signals.push({
2334
+ key: `${channel.id}:${message.ts}`,
2335
+ kind: "message",
2336
+ ref: `${channel.id}:${message.ts}`,
2337
+ title: summarise$2(message.text),
2338
+ url: permalink$1(account, channel.id, message.ts),
2339
+ payload: asked
2340
+ });
2341
+ }
2342
+ }
2343
+ return {
2344
+ signals,
2345
+ cursor: next
2346
+ };
2347
+ },
2348
+ async resolveWorkItem({ payload }) {
2349
+ const asked = asAsked$2(payload);
2350
+ return {
2351
+ kind: "message",
2352
+ ref: `${asked.channel}:${asked.ts}`,
2353
+ title: summarise$2(asked.text),
2354
+ url: "",
2355
+ carry: {
2356
+ channel: asked.channel,
2357
+ ts: asked.threadTs
2358
+ },
2359
+ context: [{
2360
+ name: "MESSAGE.md",
2361
+ body: [
2362
+ `# What you were asked`,
2363
+ ``,
2364
+ asked.text,
2365
+ ``
2366
+ ].join("\n")
2367
+ }]
2368
+ };
2369
+ },
2370
+ async applyAction({ actionId, target, source, body, credential }) {
2371
+ if (actionId !== "slack.reply") throw new Error(`Slack cannot ${actionId}.`);
2372
+ const account = credential;
2373
+ const to = destination$1(target, source);
2374
+ const posted = await postMessage(account, {
2375
+ channel: to.channel,
2376
+ text: body.trim(),
2377
+ threadTs: to.threadTs
2378
+ });
2379
+ return { url: permalink$1(account, posted.channel, posted.ts) };
2380
+ },
2381
+ auth: {
2382
+ async connectWithFields(fields) {
2383
+ const botToken = fields.botToken?.trim() ?? "";
2384
+ if (!botToken.startsWith("xoxb-")) throw new Error("Paste the Bot User OAuth Token from your Slack app. It starts with xoxb-.");
2385
+ const credential = await authTest(botToken);
2386
+ return {
2387
+ credential,
2388
+ account: accountOf$2(credential)
2389
+ };
2390
+ },
2391
+ async identity(credential) {
2392
+ return { account: accountOf$2(await authTest(credential.botToken)) };
2393
+ }
2394
+ }
2395
+ };
2396
+ /**
2397
+ * Feishu / Lark Open API, called with a custom app's id and secret.
2398
+ * Loopable polls groups the bot is in; Feishu never has to reach this machine.
2399
+ */
2400
+ var API = {
2401
+ feishu: "https://open.feishu.cn/open-apis",
2402
+ lark: "https://open.larksuite.com/open-apis"
2403
+ };
2404
+ var APPLINK = {
2405
+ feishu: "https://applink.feishu.cn/client/chat/open",
2406
+ lark: "https://applink.larksuite.com/client/chat/open"
2407
+ };
2408
+ function parseHost(value) {
2409
+ const raw = (value ?? "feishu").trim().toLowerCase();
2410
+ if (!raw || raw === "feishu" || raw === "feishu.cn" || raw.includes("feishu.cn")) return "feishu";
2411
+ if (raw === "lark" || raw === "larksuite" || raw.includes("larksuite.com") || raw.includes("larkoffice.com")) return "lark";
2412
+ throw new Error("Open platform must be feishu (China) or lark (international).");
2413
+ }
2414
+ function refuse(code, msg) {
2415
+ if (code === 10003 || code === 10012 || code === 10014) throw new Error("Feishu did not accept those app credentials.");
2416
+ throw new Error(msg ? `Feishu refused: ${msg}` : "Feishu refused the request.");
2417
+ }
2418
+ async function call$1(host, path, init = {}) {
2419
+ const url = new URL(`${API[host]}${path}`);
2420
+ for (const [key, value] of Object.entries(init.query ?? {})) url.searchParams.set(key, value);
2421
+ let response;
2422
+ try {
2423
+ response = await fetch(url.href, {
2424
+ method: init.method ?? "GET",
2425
+ headers: {
2426
+ "Content-Type": "application/json; charset=utf-8",
2427
+ ...init.token ? { Authorization: `Bearer ${init.token}` } : {}
2428
+ },
2429
+ body: init.body ? JSON.stringify(init.body) : void 0
2430
+ });
2431
+ } catch (error) {
2432
+ throw new TransientError(`Feishu is unreachable: ${error.message}`);
2433
+ }
2434
+ if (response.status >= 500 || response.status === 429) throw new TransientError(`Feishu answered ${response.status}`);
2435
+ let body;
2436
+ try {
2437
+ body = await response.json();
2438
+ } catch {
2439
+ throw new Error(`Feishu answered ${response.status}`);
2440
+ }
2441
+ return body;
2442
+ }
2443
+ function dataOf(body) {
2444
+ if (body.code && body.code !== 0) refuse(body.code, body.msg);
2445
+ return body.data ?? {};
2446
+ }
2447
+ async function tenantAccessToken(host, appId, appSecret) {
2448
+ const body = await call$1(host, "/auth/v3/tenant_access_token/internal", {
2449
+ method: "POST",
2450
+ body: {
2451
+ app_id: appId,
2452
+ app_secret: appSecret
2453
+ }
2454
+ });
2455
+ if (body.code && body.code !== 0) refuse(body.code, body.msg);
2456
+ const token = body.tenant_access_token ?? "";
2457
+ const expire = typeof body.expire === "number" ? body.expire : 7200;
2458
+ if (!token) throw new Error("Feishu did not return a tenant token.");
2459
+ return {
2460
+ token,
2461
+ expiresAt: Date.now() + expire * 1e3 - 6e4
2462
+ };
2463
+ }
2464
+ async function botInfo(credential) {
2465
+ const body = await call$1(credential.host, "/bot/v3/info", { token: credential.tenantAccessToken });
2466
+ if (body.code && body.code !== 0) refuse(body.code, body.msg);
2467
+ const bot = body.bot ?? {};
2468
+ const openId = typeof bot.open_id === "string" ? bot.open_id : "";
2469
+ const name = typeof bot.app_name === "string" ? bot.app_name : "";
2470
+ const avatarUrl = typeof bot.avatar_url === "string" ? bot.avatar_url : void 0;
2471
+ if (!openId) throw new Error("Feishu did not return which bot this app is.");
2472
+ return {
2473
+ openId,
2474
+ name,
2475
+ ...avatarUrl ? { avatarUrl } : {}
2476
+ };
2477
+ }
2478
+ async function listChats(credential) {
2479
+ const chats = [];
2480
+ let pageToken = "";
2481
+ do {
2482
+ const query = { page_size: "100" };
2483
+ if (pageToken) query.page_token = pageToken;
2484
+ const data = dataOf(await call$1(credential.host, "/im/v1/chats", {
2485
+ token: credential.tenantAccessToken,
2486
+ query
2487
+ }));
2488
+ const items = Array.isArray(data.items) ? data.items : [];
2489
+ for (const raw of items) {
2490
+ const chat = raw;
2491
+ if (!chat.chat_id) continue;
2492
+ chats.push({
2493
+ id: chat.chat_id,
2494
+ name: chat.name ?? ""
2495
+ });
2496
+ }
2497
+ pageToken = data.has_more === true && typeof data.page_token === "string" ? data.page_token : "";
2498
+ } while (pageToken);
2499
+ return chats;
2500
+ }
2501
+ function textOf$1(content) {
2502
+ if (!content) return "";
2503
+ try {
2504
+ const parsed = JSON.parse(content);
2505
+ return typeof parsed.text === "string" ? parsed.text : content;
2506
+ } catch {
2507
+ return content;
2508
+ }
2509
+ }
2510
+ function mentionsOf(raw) {
2511
+ if (!Array.isArray(raw)) return [];
2512
+ return raw.map((entry) => {
2513
+ const mention = entry;
2514
+ return typeof mention.id === "string" ? mention.id : "";
2515
+ }).filter(Boolean);
2516
+ }
2517
+ async function history(credential, chatId, oldestMs) {
2518
+ const parsed = [];
2519
+ let pageToken = "";
2520
+ do {
2521
+ const query = {
2522
+ container_id_type: "chat",
2523
+ container_id: chatId,
2524
+ sort_type: oldestMs ? "ByCreateTimeAsc" : "ByCreateTimeDesc",
2525
+ page_size: oldestMs ? "50" : "1"
2526
+ };
2527
+ if (oldestMs) query.start_time = String(Math.floor(Number(oldestMs) / 1e3));
2528
+ if (pageToken) query.page_token = pageToken;
2529
+ const body = await call$1(credential.host, "/im/v1/messages", {
2530
+ token: credential.tenantAccessToken,
2531
+ query
2532
+ });
2533
+ if (body.code && body.code !== 0) {
2534
+ if (body.code === 230002 || body.code === 231203) return parsed;
2535
+ refuse(body.code, body.msg);
2536
+ }
2537
+ const data = body.data ?? {};
2538
+ const items = Array.isArray(data.items) ? data.items : [];
2539
+ for (const raw of items) {
2540
+ const message = raw;
2541
+ if (!message.message_id || message.deleted) continue;
2542
+ parsed.push({
2543
+ id: message.message_id,
2544
+ chatId: message.chat_id ?? chatId,
2545
+ createTime: message.create_time ?? "",
2546
+ text: textOf$1(message.body?.content),
2547
+ ...message.sender?.id ? { senderId: message.sender.id } : {},
2548
+ ...message.sender?.sender_type ? { senderType: message.sender.sender_type } : {},
2549
+ mentionIds: mentionsOf(message.mentions)
2550
+ });
2551
+ }
2552
+ pageToken = oldestMs && data.has_more === true && typeof data.page_token === "string" ? data.page_token : "";
2553
+ } while (pageToken);
2554
+ return parsed.sort((a, b) => Number(a.createTime) - Number(b.createTime));
2555
+ }
2556
+ async function replyMessage(credential, messageId, text) {
2557
+ const data = dataOf(await call$1(credential.host, `/im/v1/messages/${messageId}/reply`, {
2558
+ token: credential.tenantAccessToken,
2559
+ method: "POST",
2560
+ body: {
2561
+ msg_type: "text",
2562
+ content: JSON.stringify({ text })
2563
+ }
2564
+ }));
2565
+ const id = typeof data.message_id === "string" ? data.message_id : "";
2566
+ const chatId = typeof data.chat_id === "string" ? data.chat_id : "";
2567
+ if (!id) throw new Error("Feishu accepted the reply but did not say where it landed.");
2568
+ return {
2569
+ id,
2570
+ chatId
2571
+ };
2572
+ }
2573
+ async function sendMessage$1(credential, chatId, text) {
2574
+ const data = dataOf(await call$1(credential.host, "/im/v1/messages", {
2575
+ token: credential.tenantAccessToken,
2576
+ method: "POST",
2577
+ query: { receive_id_type: "chat_id" },
2578
+ body: {
2579
+ receive_id: chatId,
2580
+ msg_type: "text",
2581
+ content: JSON.stringify({ text })
2582
+ }
2583
+ }));
2584
+ const id = typeof data.message_id === "string" ? data.message_id : "";
2585
+ const landed = typeof data.chat_id === "string" ? data.chat_id : chatId;
2586
+ if (!id) throw new Error("Feishu accepted the message but did not say where it landed.");
2587
+ return {
2588
+ id,
2589
+ chatId: landed
2590
+ };
2591
+ }
2592
+ function permalink(host, chatId) {
2593
+ return `${APPLINK[host]}?openChatId=${chatId}`;
2594
+ }
2595
+ function asAsked$1(value) {
2596
+ const record = value;
2597
+ if (!record || typeof record.chatId !== "string" || typeof record.messageId !== "string") throw new Error("This task has lost the message it was about.");
2598
+ return {
2599
+ chatId: record.chatId,
2600
+ messageId: record.messageId,
2601
+ user: typeof record.user === "string" ? record.user : void 0,
2602
+ text: typeof record.text === "string" ? record.text : ""
2603
+ };
2604
+ }
2605
+ function asCursor(value) {
2606
+ if (!value || typeof value !== "object" || Array.isArray(value)) return {};
2607
+ const out = {};
2608
+ for (const [key, entry] of Object.entries(value)) if (typeof entry === "string") out[key] = entry;
2609
+ return out;
2610
+ }
2611
+ function summarise$1(text) {
2612
+ const line = text.replace(/@_user_\d+/g, "").replace(/\s+/g, " ").trim();
2613
+ if (!line) return "Feishu message";
2614
+ return line.length > 80 ? `${line.slice(0, 79)}…` : line;
2615
+ }
2616
+ function isAsk(input) {
2617
+ if (input.senderType === "app" || input.senderId === input.botOpenId) return false;
2618
+ return input.mentionIds.includes(input.botOpenId);
2619
+ }
2620
+ function destination(target, source) {
2621
+ if (typeof target.chat === "string" && target.chat.trim()) return { chatId: target.chat.trim() };
2622
+ if (source.connectorId !== "feishu") throw new Error("There is no Feishu chat behind this task. Choose one on the loop.");
2623
+ const carry = source.carry ?? {};
2624
+ if (!carry.chatId && !carry.messageId) throw new Error("There is no Feishu chat behind this task. Choose one on the loop.");
2625
+ return {
2626
+ chatId: carry.chatId,
2627
+ messageId: carry.messageId
2628
+ };
2629
+ }
2630
+ function accountOf$1(credential) {
2631
+ return {
2632
+ id: credential.botOpenId,
2633
+ label: credential.botName || credential.appId,
2634
+ ...credential.avatarUrl ? { avatarUrl: credential.avatarUrl } : {}
2635
+ };
2636
+ }
2637
+ /**
2638
+ * Tenant tokens last two hours. Minting on every poll would trip Feishu's
2639
+ * rate limit, so a still-valid one is reused and a fresh one is stored.
2640
+ */
2641
+ async function usable(credential) {
2642
+ if (Date.now() < credential.expiresAt) return { credential };
2643
+ const minted = await tenantAccessToken(credential.host, credential.appId, credential.appSecret);
2644
+ const refreshed = {
2645
+ ...credential,
2646
+ tenantAccessToken: minted.token,
2647
+ expiresAt: minted.expiresAt
2648
+ };
2649
+ return {
2650
+ credential: refreshed,
2651
+ refreshed
2652
+ };
2653
+ }
2654
+ var feishuRuntime = {
2655
+ async readiness() {
2656
+ return { ready: true };
2657
+ },
2658
+ async poll({ workflowId, credential, cursor }) {
2659
+ if (workflowId !== "feishu.ask") throw new Error(`Feishu cannot watch for ${workflowId}.`);
2660
+ const { credential: account } = await usable(credential);
2661
+ const previous = asCursor(cursor);
2662
+ const chats = await listChats(account);
2663
+ const next = { ...previous };
2664
+ const signals = [];
2665
+ for (const chat of chats) {
2666
+ const oldest = previous[chat.id];
2667
+ const messages = await history(account, chat.id, oldest);
2668
+ if (messages.length === 0) {
2669
+ if (!next[chat.id]) next[chat.id] = String(Date.now());
2670
+ continue;
2671
+ }
2672
+ next[chat.id] = messages[messages.length - 1].createTime;
2673
+ if (!oldest) continue;
2674
+ for (const message of messages) {
2675
+ if (oldest && Number(message.createTime) <= Number(oldest)) continue;
2676
+ if (!isAsk({
2677
+ senderId: message.senderId,
2678
+ senderType: message.senderType,
2679
+ mentionIds: message.mentionIds,
2680
+ botOpenId: account.botOpenId
2681
+ })) continue;
2682
+ const asked = {
2683
+ chatId: message.chatId,
2684
+ messageId: message.id,
2685
+ user: message.senderId,
2686
+ text: message.text
2687
+ };
2688
+ signals.push({
2689
+ key: `${message.chatId}:${message.id}`,
2690
+ kind: "message",
2691
+ ref: message.id,
2692
+ title: summarise$1(message.text),
2693
+ url: permalink(account.host, message.chatId),
2694
+ payload: asked
2695
+ });
2696
+ }
2697
+ }
2698
+ return {
2699
+ signals,
2700
+ cursor: next
2701
+ };
2702
+ },
2703
+ async resolveWorkItem({ payload }) {
2704
+ const asked = asAsked$1(payload);
2705
+ return {
2706
+ kind: "message",
2707
+ ref: asked.messageId,
2708
+ title: summarise$1(asked.text),
2709
+ url: "",
2710
+ carry: {
2711
+ chatId: asked.chatId,
2712
+ messageId: asked.messageId
2713
+ },
2714
+ context: [{
2715
+ name: "MESSAGE.md",
2716
+ body: [
2717
+ `# What you were asked`,
2718
+ ``,
2719
+ asked.text,
2720
+ ``
2721
+ ].join("\n")
2722
+ }]
2723
+ };
2724
+ },
2725
+ async applyAction({ actionId, target, source, body, credential }) {
2726
+ if (actionId !== "feishu.reply") throw new Error(`Feishu cannot ${actionId}.`);
2727
+ const { credential: account } = await usable(credential);
2728
+ const to = destination(target, source);
2729
+ const text = body.trim();
2730
+ const posted = to.messageId && source.connectorId === "feishu" ? await replyMessage(account, to.messageId, text) : await sendMessage$1(account, to.chatId ?? "", text);
2731
+ return { url: permalink(account.host, posted.chatId || to.chatId || "") };
2732
+ },
2733
+ auth: {
2734
+ async connectWithFields(fields) {
2735
+ const appId = fields.appId?.trim() ?? "";
2736
+ const appSecret = fields.appSecret?.trim() ?? "";
2737
+ if (!appId) throw new Error("Paste the App ID from your Feishu or Lark app.");
2738
+ if (!appSecret) throw new Error("Paste the App Secret from your Feishu or Lark app.");
2739
+ const host = parseHost(fields.host);
2740
+ const minted = await tenantAccessToken(host, appId, appSecret);
2741
+ const bot = await botInfo({
2742
+ host,
2743
+ tenantAccessToken: minted.token
2744
+ });
2745
+ const stored = {
2746
+ appId,
2747
+ appSecret,
2748
+ host,
2749
+ botOpenId: bot.openId,
2750
+ botName: bot.name,
2751
+ ...bot.avatarUrl ? { avatarUrl: bot.avatarUrl } : {},
2752
+ tenantAccessToken: minted.token,
2753
+ expiresAt: minted.expiresAt
2754
+ };
2755
+ return {
2756
+ credential: stored,
2757
+ account: accountOf$1(stored)
2758
+ };
2759
+ },
2760
+ async identity(credential) {
2761
+ const { credential: fresh, refreshed } = await usable(credential);
2762
+ const bot = await botInfo(fresh);
2763
+ const updated = {
2764
+ ...fresh,
2765
+ botOpenId: bot.openId,
2766
+ botName: bot.name,
2767
+ ...bot.avatarUrl ? { avatarUrl: bot.avatarUrl } : {}
2768
+ };
2769
+ return {
2770
+ account: accountOf$1(updated),
2771
+ ...refreshed ? { renewedCredential: updated } : {}
2772
+ };
2773
+ }
2774
+ }
2775
+ };
2776
+ /**
1933
2777
  * Tencent's iLink bot API, as documented for clients that are not OpenClaw:
1934
2778
  * https://github.com/Tencent/openclaw-weixin/blob/main/docs/protocol.md
1935
2779
  *
@@ -2264,6 +3108,8 @@ function accountOf(credential) {
2264
3108
  /** Server-only registry. Never import this from a component. */
2265
3109
  var RUNTIMES = {
2266
3110
  github: githubRuntime,
3111
+ slack: slackRuntime,
3112
+ feishu: feishuRuntime,
2267
3113
  gmail: gmailRuntime,
2268
3114
  wechat: {
2269
3115
  async readiness() {
@@ -2539,4 +3385,4 @@ async function checkConnection(id) {
2539
3385
  return toView(await getConnection(id));
2540
3386
  }
2541
3387
  //#endregion
2542
- export { connectorManifest as a, credentialForConnector as c, removeConnection as d, saveAuthorizedConnection as f, updateConnectionSettings as h, connectorAction as i, listConnections as l, takeAuthAttempt as m, allWorkflows as n, connectorRuntime as o, startAuthAttempt as p, checkConnection as r, connectorWorkflow as s, CONNECTOR_MANIFESTS as t, needsAccount as u };
3388
+ export { startAuthAttempt as _, connectionNoun as a, writerName as b, connectorRuntime as c, destinationLabel as d, isInboxOnly as f, saveAuthorizedConnection as g, removeConnection as h, checkConnection as i, connectorWorkflow as l, needsAccount as m, allTriggers as n, connectorAction as o, listConnections as p, allWorkflows as r, connectorManifest as s, CONNECTOR_MANIFESTS as t, credentialForConnector as u, takeAuthAttempt as v, writers as x, updateConnectionSettings as y };