loopable-cli 0.1.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/.output/nitro.json +17 -0
- package/.output/public/apple-touch-icon.png +0 -0
- package/.output/public/assets/_connectorId-BWjYn5tm.js +1 -0
- package/.output/public/assets/_loopId-Ds4RkyEc.js +1 -0
- package/.output/public/assets/_taskId-BOzICXDf.js +1 -0
- package/.output/public/assets/alert-Bn9-Zm0l.js +1 -0
- package/.output/public/assets/arrow-left-BF9juV9T.js +1 -0
- package/.output/public/assets/arrow-right-CzqvnOY1.js +1 -0
- package/.output/public/assets/button-HCY6DCY-.js +1 -0
- package/.output/public/assets/connection-status-CYZB8kLy.js +1 -0
- package/.output/public/assets/connector-icon-BZ_MUJV_.js +1 -0
- package/.output/public/assets/connectors-BvzttfSu.js +1 -0
- package/.output/public/assets/connectors-DdJNwRN_.js +1 -0
- package/.output/public/assets/external-link-D46X-bXa.js +1 -0
- package/.output/public/assets/gaps-D-I6xiDc.js +1 -0
- package/.output/public/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/.output/public/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/.output/public/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/.output/public/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/.output/public/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/.output/public/assets/inbox-BUUu9xxE.js +1 -0
- package/.output/public/assets/index-ztzex0FN.js +10 -0
- package/.output/public/assets/loader-CcBl2u8m.js +1 -0
- package/.output/public/assets/loop-form-dB3CLcZb.js +1 -0
- package/.output/public/assets/loops-DRunQrxL.js +1 -0
- package/.output/public/assets/loops-oSdqMd29.js +1 -0
- package/.output/public/assets/manifests-DNMqf7Vi.js +7 -0
- package/.output/public/assets/new-uyFxVjuB.js +1 -0
- package/.output/public/assets/preload-helper-ORbuV86A.js +3 -0
- package/.output/public/assets/runners-DSU3r5Ww.js +1 -0
- package/.output/public/assets/setting-fields-ixkpdBb-.js +3 -0
- package/.output/public/assets/styles-DZL5V1o7.css +2 -0
- package/.output/public/assets/switch-d5ummmHY.js +1 -0
- package/.output/public/assets/task-list-BpOStt9v.js +1 -0
- package/.output/public/assets/tasks-B8MsKgic.js +1 -0
- package/.output/public/assets/trash-2-D6u6NpzO.js +1 -0
- package/.output/public/favicon.svg +9 -0
- package/.output/server/_chunks/ssr-renderer.mjs +26 -0
- package/.output/server/_connectorId-8nb5yOzG.mjs +421 -0
- package/.output/server/_libs/@base-ui/react+[...].mjs +12841 -0
- package/.output/server/_libs/@tanstack/react-router+[...].mjs +14349 -0
- package/.output/server/_libs/class-variance-authority+clsx.mjs +69 -0
- package/.output/server/_libs/dijkstrajs.mjs +118 -0
- package/.output/server/_libs/drizzle-orm.mjs +4349 -0
- package/.output/server/_libs/h3+rou3+srvx.mjs +1361 -0
- package/.output/server/_libs/lucide-react.mjs +911 -0
- package/.output/server/_libs/next-themes.mjs +53 -0
- package/.output/server/_libs/pngjs.mjs +1680 -0
- package/.output/server/_libs/qrcode.mjs +2583 -0
- package/.output/server/_libs/sonner.mjs +955 -0
- package/.output/server/_libs/tailwind-merge.mjs +3380 -0
- package/.output/server/_libs/tanstack__history.mjs +341 -0
- package/.output/server/_libs/ufo.mjs +64 -0
- package/.output/server/_loopId-BsUL1SeL.mjs +272 -0
- package/.output/server/_runtime.mjs +35 -0
- package/.output/server/_ssr/access-zIl15o8y.mjs +42 -0
- package/.output/server/_ssr/agents-Btql102J.mjs +657 -0
- package/.output/server/_ssr/agents-DAOZPgO2.mjs +76 -0
- package/.output/server/_ssr/alert-NEiOCTOl.mjs +37 -0
- package/.output/server/_ssr/callback-CUINvl7M.mjs +62 -0
- package/.output/server/_ssr/client-CKkOfh-s.mjs +335 -0
- package/.output/server/_ssr/connection-status-Bi5peUAk.mjs +31 -0
- package/.output/server/_ssr/connections-qnw2GVlP.mjs +2542 -0
- package/.output/server/_ssr/connector-icon-DZXQLLFo.mjs +37 -0
- package/.output/server/_ssr/connectors-BHNwuIMj.mjs +106 -0
- package/.output/server/_ssr/connectors-C28g0i4c.mjs +83 -0
- package/.output/server/_ssr/createServerRpc-DPDrnFdH.mjs +12 -0
- package/.output/server/_ssr/empty-plugin-adapters-BJ4m9L8g.mjs +5 -0
- package/.output/server/_ssr/gaps-RAFSXLZf.mjs +53 -0
- package/.output/server/_ssr/inbox-k5zHergU.mjs +35 -0
- package/.output/server/_ssr/loop-form-DlObSewZ.mjs +412 -0
- package/.output/server/_ssr/loops-APFcP0y6.mjs +311 -0
- package/.output/server/_ssr/loops-xt2Bn7_q.mjs +222 -0
- package/.output/server/_ssr/new-DfKyd2Wa.mjs +169 -0
- package/.output/server/_ssr/router-3WmpE5b_.mjs +1553 -0
- package/.output/server/_ssr/runners-By-mwc3F.mjs +32 -0
- package/.output/server/_ssr/runners-DI_NxpHC.mjs +116 -0
- package/.output/server/_ssr/runners-DtXs4Q8a.mjs +106 -0
- package/.output/server/_ssr/secrets-Hqti-niS.mjs +199 -0
- package/.output/server/_ssr/setting-fields-8vKFj4df.mjs +92 -0
- package/.output/server/_ssr/ssr.mjs +6605 -0
- package/.output/server/_ssr/start-Bdc2GJUI.mjs +51 -0
- package/.output/server/_ssr/switch-ReNglgH6.mjs +18 -0
- package/.output/server/_ssr/task-list-CUITIqkb.mjs +186 -0
- package/.output/server/_ssr/tasks-SAjNovX6.mjs +110 -0
- package/.output/server/_ssr/tasks-vcoodp_D.mjs +533 -0
- package/.output/server/_tanstack-start-manifest_v-C0cP5F0M.mjs +125 -0
- package/.output/server/_taskId-CXS3lod0.mjs +249 -0
- package/.output/server/index.mjs +541 -0
- package/README.md +81 -0
- package/dist/cli.mjs +165 -0
- package/dist/dispatcher.mjs +9871 -0
- package/dist/runner.mjs +657 -0
- package/drizzle/0000_cold_demogoblin.sql +29 -0
- package/drizzle/0001_fancy_blonde_phantom.sql +13 -0
- package/drizzle/0002_spooky_smiling_tiger.sql +16 -0
- package/drizzle/0003_small_golden_guardian.sql +25 -0
- package/drizzle/0004_wonderful_thunderbolts.sql +39 -0
- package/drizzle/0005_named_workflows.sql +7 -0
- package/drizzle/0006_signals.sql +18 -0
- package/drizzle/0007_review_comments.sql +1 -0
- package/drizzle/0008_held_signals.sql +1 -0
- package/drizzle/0009_work_item_ref.sql +58 -0
- package/drizzle/0010_signal_payload.sql +2 -0
- package/drizzle/0011_rule_poll_cursor.sql +1 -0
- package/drizzle/0012_loops.sql +63 -0
- package/drizzle/0013_loop_owns_action_and_prompt.sql +154 -0
- package/drizzle/0014_loop_poll_every.sql +4 -0
- package/drizzle/0015_machines.sql +10 -0
- package/drizzle/0016_task_machine.sql +1 -0
- package/drizzle/0017_loop_machine.sql +1 -0
- package/drizzle/0018_runners.sql +1 -0
- package/drizzle/0019_task_runner.sql +1 -0
- package/drizzle/0020_loop_runner.sql +1 -0
- package/drizzle/0021_drop_runner_kind.sql +1 -0
- package/drizzle/0022_drop_loop_runner.sql +1 -0
- package/drizzle/0023_shared_host.sql +6 -0
- package/drizzle/meta/0000_snapshot.json +206 -0
- package/drizzle/meta/0001_snapshot.json +284 -0
- package/drizzle/meta/0002_snapshot.json +390 -0
- package/drizzle/meta/0003_snapshot.json +566 -0
- package/drizzle/meta/0004_snapshot.json +633 -0
- package/drizzle/meta/0005_snapshot.json +626 -0
- package/drizzle/meta/0006_snapshot.json +752 -0
- package/drizzle/meta/0007_snapshot.json +759 -0
- package/drizzle/meta/0008_snapshot.json +766 -0
- package/drizzle/meta/0009_snapshot.json +752 -0
- package/drizzle/meta/0010_snapshot.json +766 -0
- package/drizzle/meta/0011_snapshot.json +773 -0
- package/drizzle/meta/0012_snapshot.json +773 -0
- package/drizzle/meta/0013_snapshot.json +817 -0
- package/drizzle/meta/_journal.json +174 -0
- package/package.json +88 -0
|
@@ -0,0 +1,2542 @@
|
|
|
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-CKkOfh-s.mjs";
|
|
3
|
+
import { i as readCredential, s as writeCredential, t as deleteCredential } from "./secrets-Hqti-niS.mjs";
|
|
4
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
//#region node_modules/.nitro/vite/services/ssr/assets/connections-qnw2GVlP.js
|
|
8
|
+
function defineManifest(manifest) {
|
|
9
|
+
return manifest;
|
|
10
|
+
}
|
|
11
|
+
function defineRuntime(runtime) {
|
|
12
|
+
return runtime;
|
|
13
|
+
}
|
|
14
|
+
var GITHUB_SCOPES = ["repo", "notifications"];
|
|
15
|
+
var repositories = {
|
|
16
|
+
key: "repositories",
|
|
17
|
+
kind: "string_list",
|
|
18
|
+
label: "Repositories",
|
|
19
|
+
help: "owner/name, one per line. Wildcards work: acme/* or */*. Leave empty for every repository you can see.",
|
|
20
|
+
placeholder: "acme/web"
|
|
21
|
+
};
|
|
22
|
+
var ignoreDrafts = {
|
|
23
|
+
key: "ignoreDrafts",
|
|
24
|
+
kind: "boolean",
|
|
25
|
+
label: "Skip draft pull requests",
|
|
26
|
+
default: true
|
|
27
|
+
};
|
|
28
|
+
var ignoreBots = {
|
|
29
|
+
key: "ignoreBots",
|
|
30
|
+
kind: "boolean",
|
|
31
|
+
label: "Skip pull requests opened by bots",
|
|
32
|
+
help: "Dependabot and friends open a great many, and each one costs a full run.",
|
|
33
|
+
default: true
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Not a quality setting so much as a cost one: the largest pull requests give
|
|
37
|
+
* the shallowest reviews and take the longest to produce them.
|
|
38
|
+
*/
|
|
39
|
+
var sizeLimit = {
|
|
40
|
+
key: "maxChangedFiles",
|
|
41
|
+
kind: "select",
|
|
42
|
+
label: "Skip very large pull requests",
|
|
43
|
+
options: [
|
|
44
|
+
{
|
|
45
|
+
value: "0",
|
|
46
|
+
label: "Never skip"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
value: "25",
|
|
50
|
+
label: "More than 25 files changed"
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
value: "50",
|
|
54
|
+
label: "More than 50 files changed"
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
value: "100",
|
|
58
|
+
label: "More than 100 files changed"
|
|
59
|
+
}
|
|
60
|
+
],
|
|
61
|
+
default: "50"
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Left empty by every loop that answers what it read, which is most of them.
|
|
65
|
+
* Filled in when the trigger is somewhere else entirely and there is no issue
|
|
66
|
+
* in it to comment on.
|
|
67
|
+
*/
|
|
68
|
+
var issueTarget = {
|
|
69
|
+
key: "issue",
|
|
70
|
+
kind: "text",
|
|
71
|
+
label: "Issue or pull request",
|
|
72
|
+
help: "A GitHub URL. Leave empty to write back to whatever triggered the loop.",
|
|
73
|
+
placeholder: "https://github.com/acme/web/issues/12"
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Written once, here, rather than left to whoever creates the loop. A review
|
|
77
|
+
* is a well-understood job and the connector should be good at it by default.
|
|
78
|
+
*/
|
|
79
|
+
var REVIEW_PROMPT = [
|
|
80
|
+
"Review this pull request the way an experienced engineer on this team would.",
|
|
81
|
+
"",
|
|
82
|
+
"Look for correctness bugs, security problems, races, unhandled errors, and",
|
|
83
|
+
"changes that quietly break existing behaviour. Check whether the change is",
|
|
84
|
+
"tested, and say plainly when it is not.",
|
|
85
|
+
"",
|
|
86
|
+
"Point at specific files and lines rather than describing the change back to",
|
|
87
|
+
"its author. Say when something is done well; a review that lists only faults",
|
|
88
|
+
"reads as hostile. Skip anything a linter or formatter already enforces, and",
|
|
89
|
+
"do not restate what the diff obviously does.",
|
|
90
|
+
"",
|
|
91
|
+
"Where you cannot see enough of the code to judge something, say so instead of",
|
|
92
|
+
"guessing."
|
|
93
|
+
].join("\n");
|
|
94
|
+
var PLAN_PROMPT = [
|
|
95
|
+
"Write a short implementation plan for this issue.",
|
|
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.",
|
|
100
|
+
"",
|
|
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."
|
|
118
|
+
].join("\n");
|
|
119
|
+
var githubManifest = defineManifest({
|
|
120
|
+
id: "github",
|
|
121
|
+
name: "GitHub",
|
|
122
|
+
tagline: "Pick up review requests and assigned issues.",
|
|
123
|
+
docsUrl: "https://docs.github.com/rest",
|
|
124
|
+
icon: "Github",
|
|
125
|
+
accent: "bg-neutral-900 text-white",
|
|
126
|
+
auth: {
|
|
127
|
+
kind: "oauth_redirect",
|
|
128
|
+
scopes: GITHUB_SCOPES,
|
|
129
|
+
needsAppRegistration: true
|
|
130
|
+
},
|
|
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
|
+
],
|
|
203
|
+
settings: [],
|
|
204
|
+
allowsMultipleAccounts: false,
|
|
205
|
+
byHand: {
|
|
206
|
+
kind: "link",
|
|
207
|
+
placeholder: "https://github.com/acme/web/pull/123"
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
/**
|
|
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.
|
|
258
|
+
*/
|
|
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");
|
|
287
|
+
var gmailManifest = defineManifest({
|
|
288
|
+
id: "gmail",
|
|
289
|
+
name: "Gmail",
|
|
290
|
+
tagline: "Read new mail and have an agent tell you what matters.",
|
|
291
|
+
docsUrl: "https://developers.google.com/gmail/api/guides",
|
|
292
|
+
icon: "Gmail",
|
|
293
|
+
accent: "bg-[#ea4335] text-white",
|
|
294
|
+
auth: {
|
|
295
|
+
kind: "oauth_redirect",
|
|
296
|
+
scopes: GMAIL_SCOPES,
|
|
297
|
+
needsAppRegistration: true
|
|
298
|
+
},
|
|
299
|
+
workflows: [{
|
|
300
|
+
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",
|
|
304
|
+
watches: "in:inbox newer_than:1d",
|
|
305
|
+
writes: "a message telling you what arrived",
|
|
306
|
+
runsIn: "temp",
|
|
307
|
+
settings: [
|
|
308
|
+
extraQuery,
|
|
309
|
+
needsMeOnly,
|
|
310
|
+
perPoll,
|
|
311
|
+
skipOwn
|
|
312
|
+
],
|
|
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
|
+
answer: "text",
|
|
316
|
+
actionConnectorId: "wechat",
|
|
317
|
+
actionId: "wechat.reply",
|
|
318
|
+
actionTarget: { to: "me" },
|
|
319
|
+
pollEveryMs: 18e5
|
|
320
|
+
}],
|
|
321
|
+
actions: [],
|
|
322
|
+
settings: [],
|
|
323
|
+
allowsMultipleAccounts: false,
|
|
324
|
+
byHand: { kind: "none" }
|
|
325
|
+
});
|
|
326
|
+
var WEEKDAY_NAMES = [
|
|
327
|
+
"Sunday",
|
|
328
|
+
"Monday",
|
|
329
|
+
"Tuesday",
|
|
330
|
+
"Wednesday",
|
|
331
|
+
"Thursday",
|
|
332
|
+
"Friday",
|
|
333
|
+
"Saturday"
|
|
334
|
+
];
|
|
335
|
+
function parseTime(at) {
|
|
336
|
+
const match = /^\s*(\d{1,2})\s*:\s*(\d{2})\s*$/.exec(at);
|
|
337
|
+
if (!match) throw new Error(`"${at}" is not a time. Write it as 09:00, on a 24-hour clock.`);
|
|
338
|
+
const hour = Number(match[1]);
|
|
339
|
+
const minute = Number(match[2]);
|
|
340
|
+
if (hour > 23 || minute > 59) throw new Error(`There is no such time as ${at}.`);
|
|
341
|
+
return {
|
|
342
|
+
hour,
|
|
343
|
+
minute
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* The schedule a loop's settings describe.
|
|
348
|
+
*
|
|
349
|
+
* Throws rather than falling back to a default, because a loop that was meant
|
|
350
|
+
* to run at seven and quietly runs at midnight is worse than one that says it
|
|
351
|
+
* cannot read its own settings.
|
|
352
|
+
*/
|
|
353
|
+
function scheduleFrom(settings) {
|
|
354
|
+
const every = typeof settings.every === "string" ? settings.every : "day";
|
|
355
|
+
const at = typeof settings.at === "string" && settings.at.trim() !== "" ? settings.at : "09:00";
|
|
356
|
+
if (every === "hour") return {
|
|
357
|
+
every: "hour",
|
|
358
|
+
minute: parseTime(at).minute
|
|
359
|
+
};
|
|
360
|
+
if (every === "day") return {
|
|
361
|
+
every: "day",
|
|
362
|
+
...parseTime(at)
|
|
363
|
+
};
|
|
364
|
+
if (every === "weekday") return {
|
|
365
|
+
every: "weekday",
|
|
366
|
+
...parseTime(at)
|
|
367
|
+
};
|
|
368
|
+
if (every === "week") {
|
|
369
|
+
const raw = typeof settings.weekday === "string" ? Number(settings.weekday) : 1;
|
|
370
|
+
return {
|
|
371
|
+
every: "week",
|
|
372
|
+
weekday: Number.isInteger(raw) && raw >= 0 && raw <= 6 ? raw : 1,
|
|
373
|
+
...parseTime(at)
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
throw new Error(`Loopable does not know how to run something "${every}".`);
|
|
377
|
+
}
|
|
378
|
+
/** A local wall-clock time on the day `days` before `from`. */
|
|
379
|
+
function on(from, days, hour, minute) {
|
|
380
|
+
return new Date(from.getFullYear(), from.getMonth(), from.getDate() - days, hour, minute, 0, 0);
|
|
381
|
+
}
|
|
382
|
+
/** Whether this schedule accepts a day, which only the weekday one restricts. */
|
|
383
|
+
function accepts(schedule, when) {
|
|
384
|
+
const day = when.getDay();
|
|
385
|
+
if (schedule.every === "weekday") return day >= 1 && day <= 5;
|
|
386
|
+
if (schedule.every === "week") return day === schedule.weekday;
|
|
387
|
+
return true;
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* The most recent time this schedule came round at or before `now`.
|
|
391
|
+
*
|
|
392
|
+
* Searched backwards a day at a time rather than computed, because "the last
|
|
393
|
+
* weekday" and "the last Tuesday" are the same question asked of a calendar,
|
|
394
|
+
* and a fortnight of candidates is nothing to look through.
|
|
395
|
+
*/
|
|
396
|
+
function lastDue(schedule, now) {
|
|
397
|
+
if (schedule.every === "hour") {
|
|
398
|
+
const thisHour = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), schedule.minute, 0, 0);
|
|
399
|
+
if (thisHour <= now) return thisHour;
|
|
400
|
+
return /* @__PURE__ */ new Date(thisHour.getTime() - 36e5);
|
|
401
|
+
}
|
|
402
|
+
for (let days = 0; days < 15; days += 1) {
|
|
403
|
+
const candidate = on(now, days, schedule.hour, schedule.minute);
|
|
404
|
+
if (candidate <= now && accepts(schedule, candidate)) return candidate;
|
|
405
|
+
}
|
|
406
|
+
throw new Error("Could not work out when this last came round.");
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* The run owed since `after`, or null when none is.
|
|
410
|
+
*
|
|
411
|
+
* At most one, and the most recent, on purpose. A laptop shut for a week owes
|
|
412
|
+
* a morning summary, not seven of them, and the one worth having is today's.
|
|
413
|
+
* Which of the missed ones is chosen matters because the caller records it: the
|
|
414
|
+
* older ones are passed over rather than queued behind it.
|
|
415
|
+
*/
|
|
416
|
+
function dueSince(schedule, after, now) {
|
|
417
|
+
const last = lastDue(schedule, now);
|
|
418
|
+
return last > after ? last : null;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* A clock, as a source of work.
|
|
422
|
+
*
|
|
423
|
+
* It is a connector because everything downstream of a trigger already works:
|
|
424
|
+
* a loop watches something, what it notices is deduplicated, becomes a task,
|
|
425
|
+
* runs an agent and is written somewhere by an action. A time coming round is
|
|
426
|
+
* simply another thing to notice. What makes this one unusual is only that
|
|
427
|
+
* there is no account, which is what `auth: none` says.
|
|
428
|
+
*/
|
|
429
|
+
var every = {
|
|
430
|
+
key: "every",
|
|
431
|
+
kind: "select",
|
|
432
|
+
label: "How often",
|
|
433
|
+
options: [
|
|
434
|
+
{
|
|
435
|
+
value: "hour",
|
|
436
|
+
label: "Every hour"
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
value: "day",
|
|
440
|
+
label: "Every day"
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
value: "weekday",
|
|
444
|
+
label: "Every weekday"
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
value: "week",
|
|
448
|
+
label: "Once a week"
|
|
449
|
+
}
|
|
450
|
+
],
|
|
451
|
+
default: "day"
|
|
452
|
+
};
|
|
453
|
+
var at = {
|
|
454
|
+
key: "at",
|
|
455
|
+
kind: "text",
|
|
456
|
+
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.",
|
|
458
|
+
placeholder: "09:00"
|
|
459
|
+
};
|
|
460
|
+
var weekday = {
|
|
461
|
+
key: "weekday",
|
|
462
|
+
kind: "select",
|
|
463
|
+
label: "On",
|
|
464
|
+
help: "Only used by a loop that runs once a week.",
|
|
465
|
+
options: WEEKDAY_NAMES.map((name, index) => ({
|
|
466
|
+
value: String(index),
|
|
467
|
+
label: name
|
|
468
|
+
})),
|
|
469
|
+
default: "1"
|
|
470
|
+
};
|
|
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
|
+
/**
|
|
479
|
+
* Deliberately a frame rather than a job. Every other workflow ships a prompt
|
|
480
|
+
* because "review a pull request" is a problem worth solving once and getting
|
|
481
|
+
* right; what to do at nine in the morning is not that kind of problem, and
|
|
482
|
+
* the only person who knows it is the one setting the loop up. So this ships
|
|
483
|
+
* something that works on the day it is turned on, and says plainly that it is
|
|
484
|
+
* meant to be replaced.
|
|
485
|
+
*/
|
|
486
|
+
var RECURRING_PROMPT = [
|
|
487
|
+
"Nothing has arrived: this is running because the time came round.",
|
|
488
|
+
"",
|
|
489
|
+
"Look over the folder you are in and report anything that someone working",
|
|
490
|
+
"here would want to know today. Uncommitted work left sitting, a branch",
|
|
491
|
+
"well behind its base, failing checks, dependencies with published security",
|
|
492
|
+
"advisories: whatever is actually true of this checkout right now.",
|
|
493
|
+
"",
|
|
494
|
+
"Be brief and concrete. Name files and branches. Say nothing about what you",
|
|
495
|
+
"looked at and found fine."
|
|
496
|
+
].join("\n");
|
|
497
|
+
var scheduleManifest = defineManifest({
|
|
498
|
+
id: "schedule",
|
|
499
|
+
name: "Schedule",
|
|
500
|
+
tagline: "Run a loop when the time comes round, rather than when something arrives.",
|
|
501
|
+
icon: "Clock",
|
|
502
|
+
accent: "bg-slate-700 text-white",
|
|
503
|
+
auth: { kind: "none" },
|
|
504
|
+
allowsMultipleAccounts: false,
|
|
505
|
+
byHand: { kind: "now" },
|
|
506
|
+
settings: [],
|
|
507
|
+
workflows: [{
|
|
508
|
+
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.",
|
|
511
|
+
trigger: "the time you set comes round",
|
|
512
|
+
writes: "a note in the log",
|
|
513
|
+
runsIn: "folder",
|
|
514
|
+
settings: [
|
|
515
|
+
every,
|
|
516
|
+
at,
|
|
517
|
+
weekday,
|
|
518
|
+
folder$1
|
|
519
|
+
],
|
|
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
|
+
answer: "text",
|
|
523
|
+
actionId: "schedule.record"
|
|
524
|
+
}],
|
|
525
|
+
actions: [{
|
|
526
|
+
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.",
|
|
529
|
+
target: [],
|
|
530
|
+
accepts: ["text", "review"]
|
|
531
|
+
}]
|
|
532
|
+
});
|
|
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
|
+
};
|
|
545
|
+
/**
|
|
546
|
+
* A bot bound to your account can be messaged by other people, and this
|
|
547
|
+
* workflow runs a coding agent on a runner. Answering only yourself is the
|
|
548
|
+
* default because the alternative hands strangers your terminal.
|
|
549
|
+
*/
|
|
550
|
+
var askers = {
|
|
551
|
+
key: "askers",
|
|
552
|
+
kind: "select",
|
|
553
|
+
label: "Who can ask",
|
|
554
|
+
options: [{
|
|
555
|
+
value: "me",
|
|
556
|
+
label: "Only me"
|
|
557
|
+
}, {
|
|
558
|
+
value: "anyone",
|
|
559
|
+
label: "Anyone who messages the bot"
|
|
560
|
+
}],
|
|
561
|
+
default: "me"
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* Whoever asked is the answer for a loop that a message started, and the only
|
|
565
|
+
* case WeChat handles properly: the reply carries the token off the message it
|
|
566
|
+
* is answering. A loop triggered somewhere else has no message behind it and
|
|
567
|
+
* so no token, which the help says plainly rather than letting someone find
|
|
568
|
+
* out from a notification that never arrives.
|
|
569
|
+
*/
|
|
570
|
+
var recipient = {
|
|
571
|
+
key: "to",
|
|
572
|
+
kind: "select",
|
|
573
|
+
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.",
|
|
575
|
+
options: [{
|
|
576
|
+
value: "source",
|
|
577
|
+
label: "Whoever asked"
|
|
578
|
+
}, {
|
|
579
|
+
value: "me",
|
|
580
|
+
label: "Me"
|
|
581
|
+
}],
|
|
582
|
+
default: "source"
|
|
583
|
+
};
|
|
584
|
+
var ASK_PROMPT = [
|
|
585
|
+
"Someone has sent you a message. Do what it asks, working in this directory,",
|
|
586
|
+
"and then answer them.",
|
|
587
|
+
"",
|
|
588
|
+
"You are answering into a chat on a phone. Be brief: a couple of short",
|
|
589
|
+
"paragraphs at most, no headings, no bullet lists unless you are genuinely",
|
|
590
|
+
"listing things. Plain sentences.",
|
|
591
|
+
"",
|
|
592
|
+
"If the message asks a question about this code, read enough of it to answer",
|
|
593
|
+
"properly rather than guessing. If it asks for a change, make the change and",
|
|
594
|
+
"say in one line what you did. If the message is too vague to act on, say what",
|
|
595
|
+
"you need to know instead of assuming."
|
|
596
|
+
].join("\n");
|
|
597
|
+
/**
|
|
598
|
+
* Client-safe registry. Imports are explicit rather than a glob so the bundler
|
|
599
|
+
* can see exactly what ships and so a typo fails the build.
|
|
600
|
+
*/
|
|
601
|
+
var CONNECTOR_MANIFESTS = [
|
|
602
|
+
githubManifest,
|
|
603
|
+
gmailManifest,
|
|
604
|
+
{
|
|
605
|
+
id: "wechat",
|
|
606
|
+
name: "WeChat",
|
|
607
|
+
tagline: "Bind a bot to your WeChat by scanning a code.",
|
|
608
|
+
docsUrl: "https://github.com/Tencent/openclaw-weixin/blob/main/docs/protocol.md",
|
|
609
|
+
icon: "Wechat",
|
|
610
|
+
accent: "bg-[#07c160] text-white",
|
|
611
|
+
auth: {
|
|
612
|
+
kind: "qr_scan",
|
|
613
|
+
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
|
+
},
|
|
615
|
+
workflows: [{
|
|
616
|
+
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",
|
|
621
|
+
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.",
|
|
625
|
+
answer: "text",
|
|
626
|
+
actionId: "wechat.reply"
|
|
627
|
+
}],
|
|
628
|
+
actions: [{
|
|
629
|
+
id: "wechat.reply",
|
|
630
|
+
name: "Reply in the chat",
|
|
631
|
+
summary: "Send a message back to whoever asked.",
|
|
632
|
+
target: [recipient],
|
|
633
|
+
accepts: ["text"]
|
|
634
|
+
}],
|
|
635
|
+
settings: [],
|
|
636
|
+
allowsMultipleAccounts: false,
|
|
637
|
+
byHand: { kind: "none" }
|
|
638
|
+
},
|
|
639
|
+
scheduleManifest
|
|
640
|
+
];
|
|
641
|
+
function connectorManifest(id) {
|
|
642
|
+
return CONNECTOR_MANIFESTS.find((manifest) => manifest.id === id);
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* Whether this connector is somewhere you have an account.
|
|
646
|
+
*
|
|
647
|
+
* 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
|
|
649
|
+
* running, and the fetch of a credential for a run.
|
|
650
|
+
*/
|
|
651
|
+
function needsAccount(id) {
|
|
652
|
+
return connectorManifest(id)?.auth.kind !== "none";
|
|
653
|
+
}
|
|
654
|
+
function connectorWorkflow(connectorId, workflowId) {
|
|
655
|
+
return connectorManifest(connectorId)?.workflows.find((entry) => entry.id === workflowId);
|
|
656
|
+
}
|
|
657
|
+
function connectorAction(connectorId, actionId) {
|
|
658
|
+
return connectorManifest(connectorId)?.actions.find((entry) => entry.id === actionId);
|
|
659
|
+
}
|
|
660
|
+
/** Every workflow on offer, for the page that asks which one to turn on. */
|
|
661
|
+
function allWorkflows() {
|
|
662
|
+
return CONNECTOR_MANIFESTS.flatMap((connector) => connector.workflows.map((workflow) => ({
|
|
663
|
+
connector,
|
|
664
|
+
workflow
|
|
665
|
+
})));
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* App registrations Loopable ships. End users never set these; they only click
|
|
669
|
+
* Connect. A client on the user's machine cannot keep clientSecret confidential,
|
|
670
|
+
* which both GitHub and Google accept for public clients. PKCE is what secures
|
|
671
|
+
* the exchange. Env or config/oauth-app.json override this for GitHub Enterprise
|
|
672
|
+
* or a private registration.
|
|
673
|
+
*/
|
|
674
|
+
var SHIPPED = {
|
|
675
|
+
github: {
|
|
676
|
+
clientId: "Ov23lilxTpmZdHyRCH5f",
|
|
677
|
+
clientSecret: "478c0e954e7c573763f128ba80054aca3fe5724e"
|
|
678
|
+
},
|
|
679
|
+
gmail: {
|
|
680
|
+
clientId: "477908327191-4juu8vo8bffs63hoqlul1u2fqdch27i7.apps.googleusercontent.com",
|
|
681
|
+
clientSecret: "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"
|
|
682
|
+
}
|
|
683
|
+
};
|
|
684
|
+
function shippedOAuthApp(connectorId) {
|
|
685
|
+
return SHIPPED[connectorId] ?? null;
|
|
686
|
+
}
|
|
687
|
+
function oauthAppRegistration(connectorId) {
|
|
688
|
+
return fromEnv(connectorId) ?? fromFile(connectorId) ?? shippedOAuthApp(connectorId);
|
|
689
|
+
}
|
|
690
|
+
/** LOOPABLE_GITHUB_CLIENT_ID and friends, for a private or Enterprise app. */
|
|
691
|
+
function fromEnv(connectorId) {
|
|
692
|
+
const prefix = `LOOPABLE_${connectorId.toUpperCase()}_CLIENT_`;
|
|
693
|
+
return pick(process.env[`${prefix}ID`], process.env[`${prefix}SECRET`]);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Optional override in config/oauth-app.json. Gitignored. Either an object per
|
|
697
|
+
* connector or, for GitHub alone, the two keys at the top level.
|
|
698
|
+
*/
|
|
699
|
+
function fromFile(connectorId) {
|
|
700
|
+
let file;
|
|
701
|
+
try {
|
|
702
|
+
file = join(process.cwd(), "config", "oauth-app.json");
|
|
703
|
+
if (!existsSync(file)) return null;
|
|
704
|
+
} catch {
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
let parsed;
|
|
708
|
+
try {
|
|
709
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
710
|
+
} catch {
|
|
711
|
+
return null;
|
|
712
|
+
}
|
|
713
|
+
const nested = parsed[connectorId];
|
|
714
|
+
if (nested && typeof nested === "object") {
|
|
715
|
+
const entry = nested;
|
|
716
|
+
return pick(entry.clientId, entry.clientSecret);
|
|
717
|
+
}
|
|
718
|
+
if (connectorId !== "github") return null;
|
|
719
|
+
const flat = parsed;
|
|
720
|
+
return pick(flat.clientId, flat.clientSecret);
|
|
721
|
+
}
|
|
722
|
+
function pick(id, secret) {
|
|
723
|
+
const clientId = typeof id === "string" ? id.trim() : "";
|
|
724
|
+
const clientSecret = typeof secret === "string" ? secret.trim() : "";
|
|
725
|
+
return clientId && clientSecret ? {
|
|
726
|
+
clientId,
|
|
727
|
+
clientSecret
|
|
728
|
+
} : null;
|
|
729
|
+
}
|
|
730
|
+
/**
|
|
731
|
+
* Worth trying again: the service was unreachable, overloaded, or asked us to
|
|
732
|
+
* slow down. Connectors raise this so the dispatcher can retry without knowing
|
|
733
|
+
* anything about a particular service's status codes.
|
|
734
|
+
*/
|
|
735
|
+
var TransientError = class extends Error {
|
|
736
|
+
constructor(message) {
|
|
737
|
+
super(message);
|
|
738
|
+
this.name = "TransientError";
|
|
739
|
+
}
|
|
740
|
+
};
|
|
741
|
+
async function request$1(accessToken, path, init = {}) {
|
|
742
|
+
let res;
|
|
743
|
+
try {
|
|
744
|
+
res = await fetch(`https://api.github.com${path}`, {
|
|
745
|
+
...init,
|
|
746
|
+
headers: {
|
|
747
|
+
Authorization: `Bearer ${accessToken}`,
|
|
748
|
+
Accept: "application/vnd.github+json",
|
|
749
|
+
"User-Agent": "loopable",
|
|
750
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
751
|
+
...init.headers
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
} catch (error) {
|
|
755
|
+
throw new TransientError(`GitHub could not be reached: ${error instanceof Error ? error.message : String(error)}`);
|
|
756
|
+
}
|
|
757
|
+
if (!res.ok) {
|
|
758
|
+
const detail = (await res.text()).slice(0, 300);
|
|
759
|
+
const message = `GitHub ${res.status} on ${path}: ${detail}`;
|
|
760
|
+
const rateLimited = res.status === 403 && /rate limit|abuse|try again/i.test(detail);
|
|
761
|
+
if (res.status === 429 || res.status >= 500 || rateLimited) throw new TransientError(message);
|
|
762
|
+
throw new Error(message);
|
|
763
|
+
}
|
|
764
|
+
return await res.json();
|
|
765
|
+
}
|
|
766
|
+
function getPull(accessToken, repo, number) {
|
|
767
|
+
return request$1(accessToken, `/repos/${repo}/pulls/${number}`);
|
|
768
|
+
}
|
|
769
|
+
function getIssue(accessToken, repo, number) {
|
|
770
|
+
return request$1(accessToken, `/repos/${repo}/issues/${number}`);
|
|
771
|
+
}
|
|
772
|
+
function getRepo(accessToken, repo) {
|
|
773
|
+
return request$1(accessToken, `/repos/${repo}`);
|
|
774
|
+
}
|
|
775
|
+
function listPullFiles(accessToken, repo, number) {
|
|
776
|
+
return request$1(accessToken, `/repos/${repo}/pulls/${number}/files?per_page=100`);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Search is capped at one page on purpose. A hundred things waiting on you is
|
|
780
|
+
* already past the point where running an agent on all of them is the right
|
|
781
|
+
* answer, and paginating would only make a bad situation more expensive.
|
|
782
|
+
*/
|
|
783
|
+
async function searchIssues(accessToken, query) {
|
|
784
|
+
return (await request$1(accessToken, `/search/issues?q=${encodeURIComponent(query)}&per_page=100&advanced_search=true`)).items;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Reviews are submitted as COMMENT, never APPROVE: a loop may say what it
|
|
788
|
+
* thinks, but whether a pull request is approved stays a human judgement.
|
|
789
|
+
*
|
|
790
|
+
* A comment on a line outside the diff makes GitHub refuse the whole review,
|
|
791
|
+
* so callers check their anchors before getting here.
|
|
792
|
+
*/
|
|
793
|
+
function submitReview(accessToken, repo, pull, body, comments = []) {
|
|
794
|
+
return request$1(accessToken, `/repos/${repo}/pulls/${pull}/reviews`, {
|
|
795
|
+
method: "POST",
|
|
796
|
+
body: JSON.stringify({
|
|
797
|
+
event: "COMMENT",
|
|
798
|
+
body,
|
|
799
|
+
...comments.length > 0 ? { comments } : {}
|
|
800
|
+
})
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
function postIssueComment(accessToken, repo, issue, body) {
|
|
804
|
+
return request$1(accessToken, `/repos/${repo}/issues/${issue}/comments`, {
|
|
805
|
+
method: "POST",
|
|
806
|
+
body: JSON.stringify({ body })
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* Opens a pull request for a branch that has already been pushed.
|
|
811
|
+
*
|
|
812
|
+
* Draft, always. This runs unattended, and the difference between a colleague
|
|
813
|
+
* finding something to look at and a colleague being asked to review something
|
|
814
|
+
* nobody has read is the one flag.
|
|
815
|
+
*/
|
|
816
|
+
function createPullRequest(accessToken, repo, input) {
|
|
817
|
+
return request$1(accessToken, `/repos/${repo}/pulls`, {
|
|
818
|
+
method: "POST",
|
|
819
|
+
body: JSON.stringify({
|
|
820
|
+
...input,
|
|
821
|
+
draft: true
|
|
822
|
+
})
|
|
823
|
+
});
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* The pull request already open for a branch, if there is one.
|
|
827
|
+
*
|
|
828
|
+
* A retry pushes the same branch again, and asking GitHub to open a second
|
|
829
|
+
* pull request for it fails. Finding the first one instead turns a retry into
|
|
830
|
+
* what a person would expect: the change updated, not an error.
|
|
831
|
+
*/
|
|
832
|
+
async function pullForBranch(accessToken, repo, owner, branch) {
|
|
833
|
+
return (await request$1(accessToken, `/repos/${repo}/pulls?head=${encodeURIComponent(`${owner}:${branch}`)}&state=open`))[0] ?? null;
|
|
834
|
+
}
|
|
835
|
+
async function getViewer(accessToken) {
|
|
836
|
+
const res = await fetch("https://api.github.com/user", { headers: {
|
|
837
|
+
Authorization: `Bearer ${accessToken}`,
|
|
838
|
+
Accept: "application/vnd.github+json",
|
|
839
|
+
"User-Agent": "loopable",
|
|
840
|
+
"X-GitHub-Api-Version": "2022-11-28"
|
|
841
|
+
} });
|
|
842
|
+
if (!res.ok) throw new Error(`GitHub ${res.status} on /user: ${(await res.text()).slice(0, 200)}`);
|
|
843
|
+
return {
|
|
844
|
+
user: await res.json(),
|
|
845
|
+
scopes: (res.headers.get("x-oauth-scopes") ?? "").split(",").map((scope) => scope.trim()).filter(Boolean)
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* A unified patch, read twice: once to write line numbers into it, and once to
|
|
850
|
+
* know which of those numbers a comment may be attached to.
|
|
851
|
+
*
|
|
852
|
+
* The numbers are put in the diff on purpose. An agent can work them out from
|
|
853
|
+
* the @@ headers, and gets them wrong often enough that reviews end up pointing
|
|
854
|
+
* a few lines off, which is worse than useless in a review. Telling it the
|
|
855
|
+
* number costs a few characters per line and removes the arithmetic.
|
|
856
|
+
*/
|
|
857
|
+
var HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/;
|
|
858
|
+
/**
|
|
859
|
+
* Only lines that survive into the new file get a number, because those are
|
|
860
|
+
* the only ones a comment on the right-hand side can be attached to. A removed
|
|
861
|
+
* line is shown, and deliberately left unnumbered so it cannot be aimed at.
|
|
862
|
+
*/
|
|
863
|
+
function annotate(patch) {
|
|
864
|
+
const out = [];
|
|
865
|
+
const lines = [];
|
|
866
|
+
let next = 0;
|
|
867
|
+
for (const line of patch.split("\n")) {
|
|
868
|
+
const hunk = HUNK.exec(line);
|
|
869
|
+
if (hunk) {
|
|
870
|
+
next = Number(hunk[1]);
|
|
871
|
+
out.push(` | ${line}`);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (next === 0 || line.startsWith("\\")) {
|
|
875
|
+
out.push(` | ${line}`);
|
|
876
|
+
continue;
|
|
877
|
+
}
|
|
878
|
+
const marker = line[0] ?? " ";
|
|
879
|
+
const content = line.slice(1);
|
|
880
|
+
if (marker === "-") {
|
|
881
|
+
out.push(`- | ${content}`);
|
|
882
|
+
continue;
|
|
883
|
+
}
|
|
884
|
+
out.push(`${marker}${String(next).padStart(5)} | ${content}`);
|
|
885
|
+
lines.push(next);
|
|
886
|
+
next += 1;
|
|
887
|
+
}
|
|
888
|
+
return {
|
|
889
|
+
text: out.join("\n"),
|
|
890
|
+
lines
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
/** Said once at the top of the file, rather than above every patch. */
|
|
894
|
+
var ANNOTATION_LEGEND = ["Each line is shown as `<marker><line number> | <content>`, where the number", "is the line in the new version of the file. Removed lines have no number."].join("\n");
|
|
895
|
+
var HOSTS = /* @__PURE__ */ new Set(["github.com", "www.github.com"]);
|
|
896
|
+
/**
|
|
897
|
+
* Accepts what a person actually copies out of the address bar, and checks the
|
|
898
|
+
* host properly: matching "github.com" anywhere in the text would accept
|
|
899
|
+
* https://notgithub.com/acme/web/pull/1 and then go and fetch acme/web from
|
|
900
|
+
* the real GitHub.
|
|
901
|
+
*/
|
|
902
|
+
function parseGithubUrl(input) {
|
|
903
|
+
const trimmed = input.trim();
|
|
904
|
+
if (!trimmed) return null;
|
|
905
|
+
let url;
|
|
906
|
+
try {
|
|
907
|
+
url = new URL(/^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`);
|
|
908
|
+
} catch {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
if (!HOSTS.has(url.hostname.toLowerCase())) return null;
|
|
912
|
+
const match = url.pathname.match(/^\/([^/]+)\/([^/]+)\/(pull|issues)\/(\d+)/);
|
|
913
|
+
if (!match) return null;
|
|
914
|
+
const [, owner, name, kind, number] = match;
|
|
915
|
+
return {
|
|
916
|
+
repo: `${owner}/${name}`,
|
|
917
|
+
kind: kind === "pull" ? "pull_request" : "issue",
|
|
918
|
+
number: Number(number)
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/** How GitHub names a thing everywhere outside itself: `owner/name#12`. */
|
|
922
|
+
function githubRef(repo, number) {
|
|
923
|
+
return `${repo}#${number}`;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* The other direction, for the point where a write has to name a repository
|
|
927
|
+
* and a number again. Throws rather than returns null: a ref that this
|
|
928
|
+
* connector wrote and cannot read back is a bug, not a bad input.
|
|
929
|
+
*/
|
|
930
|
+
function parseGithubRef(ref) {
|
|
931
|
+
const match = ref.match(/^([^/\s]+\/[^/\s#]+)#(\d+)$/);
|
|
932
|
+
if (!match) throw new Error(`Not a GitHub ref: ${ref}`);
|
|
933
|
+
return {
|
|
934
|
+
repo: match[1],
|
|
935
|
+
number: Number(match[2])
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Generous, because a review written against half a diff is worse than no
|
|
940
|
+
* review: the agent hedges everything it could not see. Nothing is cut while
|
|
941
|
+
* the whole diff fits, and where a cut is unavoidable it is announced, so the
|
|
942
|
+
* agent knows to stay quiet about the rest instead of guessing.
|
|
943
|
+
*/
|
|
944
|
+
var TOTAL_LIMIT = 2e5;
|
|
945
|
+
/** No file is cut below this, however many files there are. */
|
|
946
|
+
var MIN_PER_FILE = 8e3;
|
|
947
|
+
/**
|
|
948
|
+
* A file's patch as the agent sees it, and the lines it may comment on. A cut
|
|
949
|
+
* patch keeps only the anchors that are still visible: a comment on a line the
|
|
950
|
+
* agent was never shown would be a guess.
|
|
951
|
+
*/
|
|
952
|
+
function patchOf(file, budget) {
|
|
953
|
+
const patch = file.patch;
|
|
954
|
+
if (!patch) return {
|
|
955
|
+
body: file.status === "renamed" ? "_Renamed, no content change._" : "_No diff available._",
|
|
956
|
+
lines: []
|
|
957
|
+
};
|
|
958
|
+
const whole = patch.length <= budget ? patch : patch.slice(0, patch.lastIndexOf("\n", budget));
|
|
959
|
+
const { text, lines } = annotate(whole);
|
|
960
|
+
const block = `\`\`\`diff\n${text}\n\`\`\``;
|
|
961
|
+
if (patch.length <= budget) return {
|
|
962
|
+
body: block,
|
|
963
|
+
lines
|
|
964
|
+
};
|
|
965
|
+
return {
|
|
966
|
+
body: [block, `**The diff for this file was cut here: ${patch.slice(whole.length).split("\n").length} more lines are not shown. Do not draw conclusions about the part you cannot see.**`].join("\n\n"),
|
|
967
|
+
lines
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
function diffOf(files) {
|
|
971
|
+
const share = files.reduce((sum, file) => sum + (file.patch?.length ?? 0), 0) <= TOTAL_LIMIT ? Number.POSITIVE_INFINITY : Math.max(MIN_PER_FILE, Math.floor(TOTAL_LIMIT / files.length));
|
|
972
|
+
const sections = [ANNOTATION_LEGEND];
|
|
973
|
+
const commentable = {};
|
|
974
|
+
let budget = TOTAL_LIMIT;
|
|
975
|
+
let shown = 0;
|
|
976
|
+
for (const file of files) {
|
|
977
|
+
if (budget <= 0) break;
|
|
978
|
+
const counts = [file.additions === void 0 ? null : `+${file.additions}`, file.deletions === void 0 ? null : `-${file.deletions}`].filter(Boolean).join(" ");
|
|
979
|
+
const { body, lines } = patchOf(file, Math.min(share, budget));
|
|
980
|
+
sections.push(`### ${file.filename}\n\n${file.status}${counts ? ` ${counts}` : ""}\n\n${body}`);
|
|
981
|
+
if (lines.length > 0) commentable[file.filename] = lines;
|
|
982
|
+
budget -= body.length;
|
|
983
|
+
shown += 1;
|
|
984
|
+
}
|
|
985
|
+
const omitted = files.length - shown;
|
|
986
|
+
if (omitted > 0) sections.push(`**${omitted} more changed files are not shown at all: ${files.slice(shown).map((file) => file.filename).join(", ")}**`);
|
|
987
|
+
return {
|
|
988
|
+
body: sections.join("\n\n"),
|
|
989
|
+
commentable
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
function filesOverview(files) {
|
|
993
|
+
if (files.length === 0) return "_No files changed._";
|
|
994
|
+
return files.map((file) => {
|
|
995
|
+
const counts = [file.additions === void 0 ? null : `+${file.additions}`, file.deletions === void 0 ? null : `-${file.deletions}`].filter(Boolean).join(" ");
|
|
996
|
+
return `- ${file.filename} (${file.status}${counts ? ` ${counts}` : ""})`;
|
|
997
|
+
}).join("\n");
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* Metadata for the agent. The Connection token stays here: it is used to read
|
|
1001
|
+
* the pull request and to know which lines a comment may land on. Clone URL and
|
|
1002
|
+
* ref are written into the files the agent reads, with no token on them.
|
|
1003
|
+
*/
|
|
1004
|
+
async function resolveWorkItem(url, accessToken) {
|
|
1005
|
+
const parsed = parseGithubUrl(url);
|
|
1006
|
+
if (!parsed) throw new Error("That does not look like a GitHub pull request or issue link.");
|
|
1007
|
+
const cloneUrl = `https://github.com/${parsed.repo}.git`;
|
|
1008
|
+
if (parsed.kind === "pull_request") {
|
|
1009
|
+
const pull = await getPull(accessToken, parsed.repo, parsed.number);
|
|
1010
|
+
const files = await listPullFiles(accessToken, parsed.repo, parsed.number);
|
|
1011
|
+
return {
|
|
1012
|
+
kind: "pull_request",
|
|
1013
|
+
commentable: diffOf(files).commentable,
|
|
1014
|
+
checkout: {
|
|
1015
|
+
url: cloneUrl,
|
|
1016
|
+
ref: `pull/${parsed.number}/head`,
|
|
1017
|
+
sha: pull.head.sha,
|
|
1018
|
+
base: pull.base.ref,
|
|
1019
|
+
repo: parsed.repo
|
|
1020
|
+
},
|
|
1021
|
+
ref: githubRef(parsed.repo, pull.number),
|
|
1022
|
+
title: pull.title,
|
|
1023
|
+
url: pull.html_url,
|
|
1024
|
+
context: [{
|
|
1025
|
+
name: "PULL_REQUEST.md",
|
|
1026
|
+
body: [
|
|
1027
|
+
`# ${pull.title}`,
|
|
1028
|
+
``,
|
|
1029
|
+
`Repository: ${parsed.repo}`,
|
|
1030
|
+
`Pull request: #${pull.number}`,
|
|
1031
|
+
`Opened by: ${pull.user.login}`,
|
|
1032
|
+
`Draft: ${pull.draft ? "yes" : "no"}`,
|
|
1033
|
+
`URL: ${pull.html_url}`,
|
|
1034
|
+
``,
|
|
1035
|
+
`Clone: ${cloneUrl}`,
|
|
1036
|
+
`Ref: pull/${parsed.number}/head`,
|
|
1037
|
+
``,
|
|
1038
|
+
`## Description`,
|
|
1039
|
+
``,
|
|
1040
|
+
pull.body?.trim() || "_No description._",
|
|
1041
|
+
``,
|
|
1042
|
+
`## Changed files`,
|
|
1043
|
+
``,
|
|
1044
|
+
filesOverview(files)
|
|
1045
|
+
].join("\n")
|
|
1046
|
+
}]
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
const issue = await getIssue(accessToken, parsed.repo, parsed.number);
|
|
1050
|
+
const repo = await getRepo(accessToken, parsed.repo);
|
|
1051
|
+
return {
|
|
1052
|
+
kind: "issue",
|
|
1053
|
+
checkout: {
|
|
1054
|
+
url: cloneUrl,
|
|
1055
|
+
ref: repo.default_branch,
|
|
1056
|
+
base: repo.default_branch,
|
|
1057
|
+
repo: parsed.repo
|
|
1058
|
+
},
|
|
1059
|
+
ref: githubRef(parsed.repo, issue.number),
|
|
1060
|
+
title: issue.title,
|
|
1061
|
+
url: issue.html_url,
|
|
1062
|
+
context: [{
|
|
1063
|
+
name: "ISSUE.md",
|
|
1064
|
+
body: [
|
|
1065
|
+
`# ${issue.title}`,
|
|
1066
|
+
``,
|
|
1067
|
+
`Repository: ${parsed.repo}`,
|
|
1068
|
+
`Issue: #${issue.number}`,
|
|
1069
|
+
`Opened by: ${issue.user.login}`,
|
|
1070
|
+
`URL: ${issue.html_url}`,
|
|
1071
|
+
``,
|
|
1072
|
+
`Clone: ${cloneUrl}`,
|
|
1073
|
+
`Ref: ${repo.default_branch}`,
|
|
1074
|
+
``,
|
|
1075
|
+
`## Description`,
|
|
1076
|
+
``,
|
|
1077
|
+
issue.body?.trim() || "_No description._"
|
|
1078
|
+
].join("\n")
|
|
1079
|
+
}]
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Search tells you which repository a result came from only through its API
|
|
1084
|
+
* URL, so the owner and name are read back out of it.
|
|
1085
|
+
*/
|
|
1086
|
+
function repoFromApiUrl(url) {
|
|
1087
|
+
const match = /^https:\/\/api\.github\.com\/repos\/([^/]+)\/([^/]+)$/.exec(url);
|
|
1088
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
1089
|
+
}
|
|
1090
|
+
/** An empty list means every repository the account can see. */
|
|
1091
|
+
function repoMatches(repo, patterns) {
|
|
1092
|
+
if (patterns.length === 0) return true;
|
|
1093
|
+
const target = repo.toLowerCase();
|
|
1094
|
+
return patterns.some((pattern) => {
|
|
1095
|
+
const escaped = pattern.trim().toLowerCase().replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*");
|
|
1096
|
+
return escaped !== "" && new RegExp(`^${escaped}$`).test(target);
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
/** Apps get a login ending in [bot]; the type is set for both apps and users. */
|
|
1100
|
+
function isBot(user) {
|
|
1101
|
+
return user.type === "Bot" || user.login.endsWith("[bot]");
|
|
1102
|
+
}
|
|
1103
|
+
function stringList(value) {
|
|
1104
|
+
return Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
1105
|
+
}
|
|
1106
|
+
/**
|
|
1107
|
+
* Missing means "the workflow's default", not "off". A loop saved before a
|
|
1108
|
+
* setting existed must behave as if it had been there all along.
|
|
1109
|
+
*/
|
|
1110
|
+
function boolean$1(value, fallback) {
|
|
1111
|
+
return typeof value === "boolean" ? value : fallback;
|
|
1112
|
+
}
|
|
1113
|
+
function count$1(value, fallback) {
|
|
1114
|
+
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
1115
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
1116
|
+
}
|
|
1117
|
+
function candidates(items, repositories) {
|
|
1118
|
+
return items.flatMap((item) => {
|
|
1119
|
+
const repo = repoFromApiUrl(item.repository_url);
|
|
1120
|
+
return repo && repoMatches(repo, repositories) ? [{
|
|
1121
|
+
repo,
|
|
1122
|
+
item
|
|
1123
|
+
}] : [];
|
|
1124
|
+
});
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* What the workflow says it watches for, which is what gets asked. Taking the
|
|
1128
|
+
* query from the manifest is the point: it is shown on the loop page, and a
|
|
1129
|
+
* search that quietly differed from the one on screen would be the worst kind
|
|
1130
|
+
* of wrong.
|
|
1131
|
+
*/
|
|
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;
|
|
1136
|
+
}
|
|
1137
|
+
async function pollGithub(input) {
|
|
1138
|
+
const repositories = stringList(input.settings.repositories);
|
|
1139
|
+
if (input.workflowId === "github.review_requested") {
|
|
1140
|
+
const items = await searchIssues(input.accessToken, query(input.workflowId));
|
|
1141
|
+
const ignoreDrafts = boolean$1(input.settings.ignoreDrafts, true);
|
|
1142
|
+
const ignoreBots = boolean$1(input.settings.ignoreBots, true);
|
|
1143
|
+
const maxFiles = count$1(input.settings.maxChangedFiles, 50);
|
|
1144
|
+
const signals = [];
|
|
1145
|
+
for (const { repo, item } of candidates(items, repositories)) {
|
|
1146
|
+
if (ignoreBots && isBot(item.user)) continue;
|
|
1147
|
+
if (ignoreDrafts && item.draft) continue;
|
|
1148
|
+
const pull = await getPull(input.accessToken, repo, item.number);
|
|
1149
|
+
if (ignoreDrafts && pull.draft) continue;
|
|
1150
|
+
const tooBig = maxFiles > 0 && pull.changed_files > maxFiles;
|
|
1151
|
+
signals.push({
|
|
1152
|
+
key: `${githubRef(repo, item.number)}@${pull.head.sha}`,
|
|
1153
|
+
kind: "pull_request",
|
|
1154
|
+
ref: githubRef(repo, item.number),
|
|
1155
|
+
title: item.title,
|
|
1156
|
+
url: item.html_url,
|
|
1157
|
+
hold: tooBig ? `${pull.changed_files} files changed, over this loop's ${maxFiles}` : void 0
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
return signals;
|
|
1161
|
+
}
|
|
1162
|
+
if (input.workflowId === "github.issue_assigned") return candidates(await searchIssues(input.accessToken, query(input.workflowId)), repositories).map(({ repo, item }) => ({
|
|
1163
|
+
key: githubRef(repo, item.number),
|
|
1164
|
+
kind: "issue",
|
|
1165
|
+
ref: githubRef(repo, item.number),
|
|
1166
|
+
title: item.title,
|
|
1167
|
+
url: item.html_url
|
|
1168
|
+
}));
|
|
1169
|
+
throw new Error(`GitHub cannot watch for ${input.workflowId}.`);
|
|
1170
|
+
}
|
|
1171
|
+
function createPkce$1() {
|
|
1172
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1173
|
+
return {
|
|
1174
|
+
verifier,
|
|
1175
|
+
challenge: createHash("sha256").update(verifier).digest("base64url")
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
function buildAuthorizeUrl$1(input) {
|
|
1179
|
+
return `https://github.com/login/oauth/authorize?${new URLSearchParams({
|
|
1180
|
+
client_id: input.clientId,
|
|
1181
|
+
redirect_uri: input.redirectUri,
|
|
1182
|
+
scope: GITHUB_SCOPES.join(" "),
|
|
1183
|
+
state: input.state,
|
|
1184
|
+
code_challenge: input.challenge,
|
|
1185
|
+
code_challenge_method: "S256"
|
|
1186
|
+
}).toString()}`;
|
|
1187
|
+
}
|
|
1188
|
+
async function postToken$1(payload, fetchImpl) {
|
|
1189
|
+
const text = await (await fetchImpl("https://github.com/login/oauth/access_token", {
|
|
1190
|
+
method: "POST",
|
|
1191
|
+
headers: {
|
|
1192
|
+
Accept: "application/json",
|
|
1193
|
+
"Content-Type": "application/json"
|
|
1194
|
+
},
|
|
1195
|
+
body: JSON.stringify(payload)
|
|
1196
|
+
})).text();
|
|
1197
|
+
let body;
|
|
1198
|
+
try {
|
|
1199
|
+
body = JSON.parse(text);
|
|
1200
|
+
} catch {
|
|
1201
|
+
throw new Error(`GitHub returned a non-JSON response: ${text.slice(0, 200)}`);
|
|
1202
|
+
}
|
|
1203
|
+
const error = typeof body.error === "string" ? body.error : "";
|
|
1204
|
+
if (error) {
|
|
1205
|
+
const description = typeof body.error_description === "string" ? body.error_description : error;
|
|
1206
|
+
throw new Error(description);
|
|
1207
|
+
}
|
|
1208
|
+
const accessToken = typeof body.access_token === "string" ? body.access_token : "";
|
|
1209
|
+
if (!accessToken) throw new Error("GitHub did not return an access token");
|
|
1210
|
+
const expiresIn = Number(body.expires_in);
|
|
1211
|
+
return {
|
|
1212
|
+
accessToken,
|
|
1213
|
+
refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
|
|
1214
|
+
expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1e3 - 6e4 : void 0
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
function exchangeCode$1(input, fetchImpl = fetch) {
|
|
1218
|
+
return postToken$1({
|
|
1219
|
+
client_id: input.clientId,
|
|
1220
|
+
client_secret: input.clientSecret,
|
|
1221
|
+
code: input.code,
|
|
1222
|
+
redirect_uri: input.redirectUri,
|
|
1223
|
+
code_verifier: input.verifier
|
|
1224
|
+
}, fetchImpl);
|
|
1225
|
+
}
|
|
1226
|
+
function refreshCredential$1(input, fetchImpl = fetch) {
|
|
1227
|
+
return postToken$1({
|
|
1228
|
+
client_id: input.clientId,
|
|
1229
|
+
client_secret: input.clientSecret,
|
|
1230
|
+
grant_type: "refresh_token",
|
|
1231
|
+
refresh_token: input.refreshToken
|
|
1232
|
+
}, fetchImpl);
|
|
1233
|
+
}
|
|
1234
|
+
function requireRegistration$1() {
|
|
1235
|
+
const registration = oauthAppRegistration("github");
|
|
1236
|
+
if (!registration) throw new Error("GitHub is not configured in this build: the OAuth app registration is missing.");
|
|
1237
|
+
return registration;
|
|
1238
|
+
}
|
|
1239
|
+
function accountFrom(user) {
|
|
1240
|
+
return {
|
|
1241
|
+
id: String(user.user.id),
|
|
1242
|
+
label: user.user.login,
|
|
1243
|
+
url: user.user.html_url,
|
|
1244
|
+
avatarUrl: user.user.avatar_url,
|
|
1245
|
+
scopes: user.scopes
|
|
1246
|
+
};
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Where the loop was triggered, read as a GitHub ref. Only possible when
|
|
1250
|
+
* GitHub is where it came from: another connector's ref is a label this one
|
|
1251
|
+
* has no business taking apart, so a loop that crosses services has to say
|
|
1252
|
+
* where the answer goes instead of leaving it to be guessed.
|
|
1253
|
+
*/
|
|
1254
|
+
function sourceRef(source, what) {
|
|
1255
|
+
if (source.connectorId !== "github") throw new Error(`${what} needs a GitHub issue or pull request. Choose one on the loop.`);
|
|
1256
|
+
return parseGithubRef(source.ref);
|
|
1257
|
+
}
|
|
1258
|
+
function namedRef(url) {
|
|
1259
|
+
const parsed = parseGithubUrl(url);
|
|
1260
|
+
if (!parsed) throw new Error(`That is not a GitHub issue or pull request: ${url}`);
|
|
1261
|
+
return {
|
|
1262
|
+
repo: parsed.repo,
|
|
1263
|
+
number: parsed.number
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
/** The agent was asked for a subject line first, so this is that line. */
|
|
1267
|
+
function titleOf(body) {
|
|
1268
|
+
const clean = (body.split("\n").find((line) => line.trim() !== "")?.trim() ?? "").replace(/^#+\s*/, "");
|
|
1269
|
+
return clean.length > 120 ? `${clean.slice(0, 117)}…` : clean || "Changes from Loopable";
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* The description, with the two things the agent cannot say for itself: what
|
|
1273
|
+
* this came from, and how big it is. Both are what a person checks first when
|
|
1274
|
+
* a pull request appears that they did not open.
|
|
1275
|
+
*/
|
|
1276
|
+
function pullBody(input) {
|
|
1277
|
+
const closes = input.source.connectorId === "github" && input.source.kind === "issue" ? `Closes #${parseGithubRef(input.source.ref).number}` : `From [${input.source.title}](${input.source.url})`;
|
|
1278
|
+
const size = input.stat ? `\n\n<details><summary>Files changed</summary>\n\n\`\`\`\n${input.stat}\n\`\`\`\n\n</details>` : "";
|
|
1279
|
+
return `${closes}\n\n${input.body}${size}`;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Returns a usable access token, refreshing it when the stored one has expired.
|
|
1283
|
+
* The refreshed credential is handed back so the caller can persist it.
|
|
1284
|
+
*/
|
|
1285
|
+
async function usableToken$1(credential) {
|
|
1286
|
+
if (!credential.expiresAt || Date.now() < credential.expiresAt) return { accessToken: credential.accessToken };
|
|
1287
|
+
if (!credential.refreshToken) throw new Error("The GitHub token expired and there is no refresh token. Reconnect the account.");
|
|
1288
|
+
const registration = requireRegistration$1();
|
|
1289
|
+
const refreshed = await refreshCredential$1({
|
|
1290
|
+
clientId: registration.clientId,
|
|
1291
|
+
clientSecret: registration.clientSecret,
|
|
1292
|
+
refreshToken: credential.refreshToken
|
|
1293
|
+
});
|
|
1294
|
+
return {
|
|
1295
|
+
accessToken: refreshed.accessToken,
|
|
1296
|
+
refreshed
|
|
1297
|
+
};
|
|
1298
|
+
}
|
|
1299
|
+
var githubRuntime = defineRuntime({
|
|
1300
|
+
async readiness() {
|
|
1301
|
+
if (oauthAppRegistration("github")) return { ready: true };
|
|
1302
|
+
return {
|
|
1303
|
+
ready: false,
|
|
1304
|
+
reason: "This build has no GitHub app registration.",
|
|
1305
|
+
fixHint: "Set LOOPABLE_GITHUB_CLIENT_ID and LOOPABLE_GITHUB_CLIENT_SECRET to use your own OAuth app."
|
|
1306
|
+
};
|
|
1307
|
+
},
|
|
1308
|
+
identifyLink(url) {
|
|
1309
|
+
const parsed = parseGithubUrl(url);
|
|
1310
|
+
if (!parsed) return null;
|
|
1311
|
+
return {
|
|
1312
|
+
kind: parsed.kind,
|
|
1313
|
+
ref: githubRef(parsed.repo, parsed.number)
|
|
1314
|
+
};
|
|
1315
|
+
},
|
|
1316
|
+
async resolveWorkItem({ url, credential }) {
|
|
1317
|
+
const { accessToken } = await usableToken$1(credential);
|
|
1318
|
+
return resolveWorkItem(url, accessToken);
|
|
1319
|
+
},
|
|
1320
|
+
async poll({ workflowId, settings, credential }) {
|
|
1321
|
+
const { accessToken } = await usableToken$1(credential);
|
|
1322
|
+
return { signals: await pollGithub({
|
|
1323
|
+
workflowId,
|
|
1324
|
+
settings,
|
|
1325
|
+
accessToken
|
|
1326
|
+
}) };
|
|
1327
|
+
},
|
|
1328
|
+
async applyAction({ actionId, target, source, body, comments, changes, credential }) {
|
|
1329
|
+
const { accessToken } = await usableToken$1(credential);
|
|
1330
|
+
if (actionId === "github.submit_review") {
|
|
1331
|
+
const { repo, number } = sourceRef(source, "A review");
|
|
1332
|
+
if (source.kind !== "pull_request") throw new Error("A review can only be submitted on a pull request.");
|
|
1333
|
+
return { url: (await submitReview(accessToken, repo, number, body, (comments ?? []).map((comment) => ({
|
|
1334
|
+
path: comment.path,
|
|
1335
|
+
line: comment.line,
|
|
1336
|
+
side: "RIGHT",
|
|
1337
|
+
...comment.startLine !== void 0 ? {
|
|
1338
|
+
start_line: comment.startLine,
|
|
1339
|
+
start_side: "RIGHT"
|
|
1340
|
+
} : {},
|
|
1341
|
+
body: comment.body
|
|
1342
|
+
})))).html_url };
|
|
1343
|
+
}
|
|
1344
|
+
if (actionId === "github.post_issue_comment") {
|
|
1345
|
+
const named = typeof target.issue === "string" ? target.issue.trim() : "";
|
|
1346
|
+
const { repo, number } = named ? namedRef(named) : sourceRef(source, "A comment");
|
|
1347
|
+
return { url: (await postIssueComment(accessToken, repo, number, body)).html_url };
|
|
1348
|
+
}
|
|
1349
|
+
if (actionId === "github.open_pull_request") {
|
|
1350
|
+
if (!changes) throw new Error("There is no branch to open a pull request for.");
|
|
1351
|
+
const repo = changes.repo;
|
|
1352
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) throw new Error(`Not a GitHub repository: ${repo}`);
|
|
1353
|
+
const owner = repo.split("/")[0];
|
|
1354
|
+
const existing = await pullForBranch(accessToken, repo, owner, changes.branch);
|
|
1355
|
+
if (existing) return { url: existing.html_url };
|
|
1356
|
+
return { url: (await createPullRequest(accessToken, repo, {
|
|
1357
|
+
title: titleOf(body),
|
|
1358
|
+
body: pullBody({
|
|
1359
|
+
body,
|
|
1360
|
+
source,
|
|
1361
|
+
stat: changes.stat
|
|
1362
|
+
}),
|
|
1363
|
+
head: changes.branch,
|
|
1364
|
+
base: changes.base
|
|
1365
|
+
})).html_url };
|
|
1366
|
+
}
|
|
1367
|
+
throw new Error(`GitHub cannot ${actionId}.`);
|
|
1368
|
+
},
|
|
1369
|
+
auth: {
|
|
1370
|
+
async startAuthorization({ redirectUri, state }) {
|
|
1371
|
+
const registration = requireRegistration$1();
|
|
1372
|
+
const { verifier, challenge } = createPkce$1();
|
|
1373
|
+
return {
|
|
1374
|
+
verifier,
|
|
1375
|
+
redirectUrl: buildAuthorizeUrl$1({
|
|
1376
|
+
clientId: registration.clientId,
|
|
1377
|
+
redirectUri,
|
|
1378
|
+
state,
|
|
1379
|
+
challenge
|
|
1380
|
+
})
|
|
1381
|
+
};
|
|
1382
|
+
},
|
|
1383
|
+
async completeAuthorization({ code, redirectUri, verifier }) {
|
|
1384
|
+
const registration = requireRegistration$1();
|
|
1385
|
+
const credential = await exchangeCode$1({
|
|
1386
|
+
clientId: registration.clientId,
|
|
1387
|
+
clientSecret: registration.clientSecret,
|
|
1388
|
+
code,
|
|
1389
|
+
redirectUri,
|
|
1390
|
+
verifier
|
|
1391
|
+
});
|
|
1392
|
+
return {
|
|
1393
|
+
credential,
|
|
1394
|
+
account: accountFrom(await getViewer(credential.accessToken))
|
|
1395
|
+
};
|
|
1396
|
+
},
|
|
1397
|
+
async identity(credential) {
|
|
1398
|
+
const { accessToken, refreshed } = await usableToken$1(credential);
|
|
1399
|
+
const account = accountFrom(await getViewer(accessToken));
|
|
1400
|
+
return refreshed ? {
|
|
1401
|
+
account,
|
|
1402
|
+
renewedCredential: refreshed
|
|
1403
|
+
} : { account };
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
});
|
|
1407
|
+
var BASE = "https://gmail.googleapis.com/gmail/v1/users/me";
|
|
1408
|
+
async function request(accessToken, path) {
|
|
1409
|
+
let res;
|
|
1410
|
+
try {
|
|
1411
|
+
res = await fetch(`${BASE}${path}`, { headers: {
|
|
1412
|
+
Authorization: `Bearer ${accessToken}`,
|
|
1413
|
+
Accept: "application/json"
|
|
1414
|
+
} });
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
throw new TransientError(`Gmail could not be reached: ${error instanceof Error ? error.message : String(error)}`);
|
|
1417
|
+
}
|
|
1418
|
+
if (!res.ok) {
|
|
1419
|
+
const detail = (await res.text()).slice(0, 300);
|
|
1420
|
+
const message = `Gmail ${res.status} on ${path}: ${detail}`;
|
|
1421
|
+
const backoff = res.status === 403 && /rateLimitExceeded|userRateLimitExceeded/i.test(detail);
|
|
1422
|
+
if (res.status === 429 || res.status >= 500 || backoff) throw new TransientError(message);
|
|
1423
|
+
throw new Error(message);
|
|
1424
|
+
}
|
|
1425
|
+
return await res.json();
|
|
1426
|
+
}
|
|
1427
|
+
function getProfile(accessToken) {
|
|
1428
|
+
return request(accessToken, "/profile");
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Message ids matching a Gmail search, newest first.
|
|
1432
|
+
*
|
|
1433
|
+
* One page only, like the GitHub search: past a certain number of unanswered
|
|
1434
|
+
* things, running an agent over all of them is the wrong answer and paging
|
|
1435
|
+
* would only make it a more expensive wrong answer.
|
|
1436
|
+
*/
|
|
1437
|
+
async function listMessages(accessToken, query, max) {
|
|
1438
|
+
return ((await request(accessToken, `/messages?q=${encodeURIComponent(query)}&maxResults=${max}`)).messages ?? []).map((entry) => entry.id);
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Just enough to recognise a message, without pulling its body down.
|
|
1442
|
+
*
|
|
1443
|
+
* Polling only needs to say what arrived and title it, and bodies are the
|
|
1444
|
+
* expensive part of a mailbox. The whole message is fetched later, once
|
|
1445
|
+
* something is actually going to be done with it.
|
|
1446
|
+
*/
|
|
1447
|
+
function getMessageHeaders(accessToken, id) {
|
|
1448
|
+
return request(accessToken, `/messages/${id}?format=metadata${[
|
|
1449
|
+
"From",
|
|
1450
|
+
"To",
|
|
1451
|
+
"Subject",
|
|
1452
|
+
"Date",
|
|
1453
|
+
"List-Id"
|
|
1454
|
+
].map((name) => `&metadataHeaders=${name}`).join("")}`);
|
|
1455
|
+
}
|
|
1456
|
+
function getMessage(accessToken, id) {
|
|
1457
|
+
return request(accessToken, `/messages/${id}?format=full`);
|
|
1458
|
+
}
|
|
1459
|
+
/** A Gmail message id, as a ref: short, stable, and unique to the mailbox. */
|
|
1460
|
+
function mailRef(id) {
|
|
1461
|
+
return `mail#${id}`;
|
|
1462
|
+
}
|
|
1463
|
+
/**
|
|
1464
|
+
* Where to send someone who wants to see the mail itself. Gmail has no
|
|
1465
|
+
* per-message API link, but the web client will open one by id.
|
|
1466
|
+
*/
|
|
1467
|
+
function permalink(id) {
|
|
1468
|
+
return `https://mail.google.com/mail/u/0/#all/${id}`;
|
|
1469
|
+
}
|
|
1470
|
+
function headerOf(message, name) {
|
|
1471
|
+
const wanted = name.toLowerCase();
|
|
1472
|
+
return (message.payload?.headers ?? []).find((header) => header.name.toLowerCase() === wanted)?.value.trim() ?? "";
|
|
1473
|
+
}
|
|
1474
|
+
/** The address out of `Some One <one@example.com>`, or the whole thing if bare. */
|
|
1475
|
+
function addressOf(from) {
|
|
1476
|
+
const angled = /<([^>]+)>/.exec(from);
|
|
1477
|
+
return (angled ? angled[1] : from).trim().toLowerCase();
|
|
1478
|
+
}
|
|
1479
|
+
/** The name out of `Some One <one@example.com>`, falling back to the address. */
|
|
1480
|
+
function senderName(from) {
|
|
1481
|
+
return from.split("<")[0].trim().replace(/^"|"$/g, "") || addressOf(from);
|
|
1482
|
+
}
|
|
1483
|
+
function decode(data) {
|
|
1484
|
+
return Buffer.from(data, "base64url").toString("utf8");
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Tags out, text kept, in the order a reader would meet it.
|
|
1488
|
+
*
|
|
1489
|
+
* Only reached for mail that carries no plain text part at all. It is not
|
|
1490
|
+
* trying to be a renderer: an agent reading this needs the words and roughly
|
|
1491
|
+
* where the line breaks were, and everything else is noise it would have to
|
|
1492
|
+
* see past.
|
|
1493
|
+
*/
|
|
1494
|
+
function htmlToText(html) {
|
|
1495
|
+
return html.replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/gi, "").replace(/<br\s*\/?>/gi, "\n").replace(/<\/(p|div|tr|li|h[1-6])>/gi, "\n").replace(/<li\b[^>]*>/gi, "- ").replace(/<[^>]+>/g, "").replace(/ /gi, " ").replace(/&/gi, "&").replace(/</gi, "<").replace(/>/gi, ">").replace(/"/gi, "\"").replace(/'|'/gi, "'").replace(/[ \t]+/g, " ").replace(/ *\n */g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
1496
|
+
}
|
|
1497
|
+
/**
|
|
1498
|
+
* The readable body, preferring what the sender wrote as text.
|
|
1499
|
+
*
|
|
1500
|
+
* A message is a tree, and a normal one from a normal client has both a plain
|
|
1501
|
+
* and an HTML version of the same words. Taking plain text where it exists
|
|
1502
|
+
* avoids ever having to guess at markup; the walk below collects both on one
|
|
1503
|
+
* pass and only falls back when there was no text part.
|
|
1504
|
+
*/
|
|
1505
|
+
function bodyText(message) {
|
|
1506
|
+
const plain = [];
|
|
1507
|
+
const html = [];
|
|
1508
|
+
const walk = (part) => {
|
|
1509
|
+
if (!part) return;
|
|
1510
|
+
const type = (part.mimeType ?? "").toLowerCase();
|
|
1511
|
+
if (!(Boolean(part.filename) || Boolean(part.body?.attachmentId)) && part.body?.data) {
|
|
1512
|
+
if (type === "text/plain") plain.push(decode(part.body.data));
|
|
1513
|
+
else if (type === "text/html") html.push(decode(part.body.data));
|
|
1514
|
+
}
|
|
1515
|
+
for (const child of part.parts ?? []) walk(child);
|
|
1516
|
+
};
|
|
1517
|
+
walk(message.payload);
|
|
1518
|
+
const text = plain.join("\n").trim();
|
|
1519
|
+
if (text) return text;
|
|
1520
|
+
const markup = html.join("\n").trim();
|
|
1521
|
+
return markup ? htmlToText(markup) : (message.snippet ?? "").trim();
|
|
1522
|
+
}
|
|
1523
|
+
/** The attachments, named only. Nothing downloads them; the agent should know. */
|
|
1524
|
+
function attachmentNames(message) {
|
|
1525
|
+
const names = [];
|
|
1526
|
+
const walk = (part) => {
|
|
1527
|
+
if (!part) return;
|
|
1528
|
+
if (part.filename) names.push(part.filename);
|
|
1529
|
+
for (const child of part.parts ?? []) walk(child);
|
|
1530
|
+
};
|
|
1531
|
+
walk(message.payload);
|
|
1532
|
+
return names;
|
|
1533
|
+
}
|
|
1534
|
+
/** Enough of a message to pick it out of a list. */
|
|
1535
|
+
function summarise$1(message) {
|
|
1536
|
+
const subject = headerOf(message, "Subject") || "(no subject)";
|
|
1537
|
+
const who = senderName(headerOf(message, "From"));
|
|
1538
|
+
const title = who ? `${who}: ${subject}` : subject;
|
|
1539
|
+
return title.length > 120 ? `${title.slice(0, 119)}…` : title;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* A long mail is cut rather than sent whole. What follows this is an agent
|
|
1543
|
+
* with a context window and, at the end of the chain, a phone: a mailing list
|
|
1544
|
+
* digest read to the last line helps nobody and costs the run.
|
|
1545
|
+
*/
|
|
1546
|
+
var BODY_LIMIT = 2e4;
|
|
1547
|
+
/** The mail as the agent reads it: one file, headers first, then the words. */
|
|
1548
|
+
function mailContext(message) {
|
|
1549
|
+
const body = bodyText(message);
|
|
1550
|
+
const clipped = body.length > BODY_LIMIT ? `${body.slice(0, BODY_LIMIT)}\n\n[cut here: the message is longer than this]` : body;
|
|
1551
|
+
const attachments = attachmentNames(message);
|
|
1552
|
+
const date = headerOf(message, "Date");
|
|
1553
|
+
return {
|
|
1554
|
+
name: "EMAIL.md",
|
|
1555
|
+
body: [
|
|
1556
|
+
`# ${headerOf(message, "Subject") || "(no subject)"}`,
|
|
1557
|
+
``,
|
|
1558
|
+
`From: ${headerOf(message, "From") || "(unknown)"}`,
|
|
1559
|
+
...headerOf(message, "To") ? [`To: ${headerOf(message, "To")}`] : [],
|
|
1560
|
+
...date ? [`Date: ${date}`] : [],
|
|
1561
|
+
...attachments.length > 0 ? [`Attachments (not included here): ${attachments.join(", ")}`] : [],
|
|
1562
|
+
``,
|
|
1563
|
+
`---`,
|
|
1564
|
+
``,
|
|
1565
|
+
clipped || "(this message has no readable text)",
|
|
1566
|
+
``
|
|
1567
|
+
].join("\n")
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* What the workflow says it watches for, which is what gets asked. Taken from
|
|
1572
|
+
* the manifest rather than written again here, because it is shown on the loop
|
|
1573
|
+
* page and a query that quietly differed from the one on screen would be the
|
|
1574
|
+
* worst kind of wrong.
|
|
1575
|
+
*/
|
|
1576
|
+
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;
|
|
1580
|
+
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Missing means "the workflow's default", not "off". A loop saved before a
|
|
1583
|
+
* setting existed must behave as if it had been there all along.
|
|
1584
|
+
*/
|
|
1585
|
+
function boolean(value, fallback) {
|
|
1586
|
+
return typeof value === "boolean" ? value : fallback;
|
|
1587
|
+
}
|
|
1588
|
+
function count(value, fallback) {
|
|
1589
|
+
const parsed = typeof value === "string" ? Number.parseInt(value, 10) : NaN;
|
|
1590
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
1591
|
+
}
|
|
1592
|
+
function text(value) {
|
|
1593
|
+
return typeof value === "string" ? value.trim() : "";
|
|
1594
|
+
}
|
|
1595
|
+
/** What Loopable asks Gmail: the workflow's query, then whatever the loop adds. */
|
|
1596
|
+
function queryFor(workflowId, settings) {
|
|
1597
|
+
const extra = text(settings.extraQuery);
|
|
1598
|
+
return extra ? `${baseQuery(workflowId)} ${extra}` : baseQuery(workflowId);
|
|
1599
|
+
}
|
|
1600
|
+
async function pollGmail(input) {
|
|
1601
|
+
if (input.workflowId !== "gmail.new_mail") throw new Error(`Gmail cannot watch for ${input.workflowId}.`);
|
|
1602
|
+
const max = count(input.settings.maxPerPoll, 10);
|
|
1603
|
+
const ids = await listMessages(input.accessToken, queryFor(input.workflowId, input.settings), max);
|
|
1604
|
+
const skipOwn = boolean(input.settings.skipOwn, true);
|
|
1605
|
+
const me = input.emailAddress.toLowerCase();
|
|
1606
|
+
const signals = [];
|
|
1607
|
+
for (const id of ids) {
|
|
1608
|
+
const message = await getMessageHeaders(input.accessToken, id);
|
|
1609
|
+
if (skipOwn && addressOf(headerOf(message, "From")) === me) continue;
|
|
1610
|
+
signals.push({
|
|
1611
|
+
key: mailRef(id),
|
|
1612
|
+
kind: "email",
|
|
1613
|
+
ref: mailRef(id),
|
|
1614
|
+
title: summarise$1(message),
|
|
1615
|
+
url: permalink(id),
|
|
1616
|
+
preview: (message.snippet ?? "").trim() || void 0,
|
|
1617
|
+
payload: { messageId: id }
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
return signals;
|
|
1621
|
+
}
|
|
1622
|
+
var AUTHORIZE = "https://accounts.google.com/o/oauth2/v2/auth";
|
|
1623
|
+
var TOKEN = "https://oauth2.googleapis.com/token";
|
|
1624
|
+
var REVOKE = "https://oauth2.googleapis.com/revoke";
|
|
1625
|
+
function createPkce() {
|
|
1626
|
+
const verifier = randomBytes(32).toString("base64url");
|
|
1627
|
+
return {
|
|
1628
|
+
verifier,
|
|
1629
|
+
challenge: createHash("sha256").update(verifier).digest("base64url")
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
function buildAuthorizeUrl(input) {
|
|
1633
|
+
return `${AUTHORIZE}?${new URLSearchParams({
|
|
1634
|
+
client_id: input.clientId,
|
|
1635
|
+
redirect_uri: input.redirectUri,
|
|
1636
|
+
response_type: "code",
|
|
1637
|
+
scope: GMAIL_SCOPES.join(" "),
|
|
1638
|
+
state: input.state,
|
|
1639
|
+
code_challenge: input.challenge,
|
|
1640
|
+
code_challenge_method: "S256",
|
|
1641
|
+
access_type: "offline",
|
|
1642
|
+
prompt: "consent"
|
|
1643
|
+
}).toString()}`;
|
|
1644
|
+
}
|
|
1645
|
+
/** Google's token endpoint takes a form, not JSON, and answers JSON either way. */
|
|
1646
|
+
async function postToken(payload, fetchImpl) {
|
|
1647
|
+
const text = await (await fetchImpl(TOKEN, {
|
|
1648
|
+
method: "POST",
|
|
1649
|
+
headers: {
|
|
1650
|
+
Accept: "application/json",
|
|
1651
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
1652
|
+
},
|
|
1653
|
+
body: new URLSearchParams(payload).toString()
|
|
1654
|
+
})).text();
|
|
1655
|
+
let body;
|
|
1656
|
+
try {
|
|
1657
|
+
body = JSON.parse(text);
|
|
1658
|
+
} catch {
|
|
1659
|
+
throw new Error(`Google returned a non-JSON response: ${text.slice(0, 200)}`);
|
|
1660
|
+
}
|
|
1661
|
+
const error = typeof body.error === "string" ? body.error : "";
|
|
1662
|
+
if (error) {
|
|
1663
|
+
const description = typeof body.error_description === "string" ? body.error_description : error;
|
|
1664
|
+
if (error === "invalid_grant") throw new Error(`Google will not renew this connection (${description}). Connect Gmail again.`);
|
|
1665
|
+
throw new Error(description);
|
|
1666
|
+
}
|
|
1667
|
+
const accessToken = typeof body.access_token === "string" ? body.access_token : "";
|
|
1668
|
+
if (!accessToken) throw new Error("Google did not return an access token");
|
|
1669
|
+
const expiresIn = Number(body.expires_in);
|
|
1670
|
+
return {
|
|
1671
|
+
accessToken,
|
|
1672
|
+
refreshToken: typeof body.refresh_token === "string" ? body.refresh_token : void 0,
|
|
1673
|
+
expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? Date.now() + expiresIn * 1e3 - 6e4 : void 0
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
function exchangeCode(input, fetchImpl = fetch) {
|
|
1677
|
+
return postToken({
|
|
1678
|
+
client_id: input.clientId,
|
|
1679
|
+
client_secret: input.clientSecret,
|
|
1680
|
+
code: input.code,
|
|
1681
|
+
redirect_uri: input.redirectUri,
|
|
1682
|
+
code_verifier: input.verifier,
|
|
1683
|
+
grant_type: "authorization_code"
|
|
1684
|
+
}, fetchImpl);
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* A renewed access token, keeping the refresh token we already hold.
|
|
1688
|
+
*
|
|
1689
|
+
* Google's refresh response has no refresh_token in it. Taking the response at
|
|
1690
|
+
* face value, as the GitHub flow can, would store a credential with no way to
|
|
1691
|
+
* renew itself and break the account an hour later.
|
|
1692
|
+
*/
|
|
1693
|
+
async function refreshCredential(input, fetchImpl = fetch) {
|
|
1694
|
+
const renewed = await postToken({
|
|
1695
|
+
client_id: input.clientId,
|
|
1696
|
+
client_secret: input.clientSecret,
|
|
1697
|
+
refresh_token: input.refreshToken,
|
|
1698
|
+
grant_type: "refresh_token"
|
|
1699
|
+
}, fetchImpl);
|
|
1700
|
+
return {
|
|
1701
|
+
...renewed,
|
|
1702
|
+
refreshToken: renewed.refreshToken ?? input.refreshToken
|
|
1703
|
+
};
|
|
1704
|
+
}
|
|
1705
|
+
async function revokeCredential(credential, fetchImpl = fetch) {
|
|
1706
|
+
const token = credential.refreshToken ?? credential.accessToken;
|
|
1707
|
+
await fetchImpl(`${REVOKE}?token=${encodeURIComponent(token)}`, {
|
|
1708
|
+
method: "POST",
|
|
1709
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" }
|
|
1710
|
+
});
|
|
1711
|
+
}
|
|
1712
|
+
function requireRegistration() {
|
|
1713
|
+
const registration = oauthAppRegistration("gmail");
|
|
1714
|
+
if (!registration) throw new Error("Gmail is not configured in this build: the OAuth app registration is missing.");
|
|
1715
|
+
return registration;
|
|
1716
|
+
}
|
|
1717
|
+
/**
|
|
1718
|
+
* A usable access token, renewed when the stored one has expired. The renewed
|
|
1719
|
+
* credential is handed back so the caller stores it instead of asking Google
|
|
1720
|
+
* again on the next call.
|
|
1721
|
+
*
|
|
1722
|
+
* Google's access tokens last an hour, so unlike GitHub this path is the
|
|
1723
|
+
* normal one rather than the exception.
|
|
1724
|
+
*/
|
|
1725
|
+
async function usableToken(credential) {
|
|
1726
|
+
if (!credential.expiresAt || Date.now() < credential.expiresAt) return { accessToken: credential.accessToken };
|
|
1727
|
+
if (!credential.refreshToken) throw new Error("The Gmail token expired and there is no refresh token. Connect Gmail again.");
|
|
1728
|
+
const registration = requireRegistration();
|
|
1729
|
+
const refreshed = await refreshCredential({
|
|
1730
|
+
clientId: registration.clientId,
|
|
1731
|
+
clientSecret: registration.clientSecret,
|
|
1732
|
+
refreshToken: credential.refreshToken
|
|
1733
|
+
});
|
|
1734
|
+
return {
|
|
1735
|
+
accessToken: refreshed.accessToken,
|
|
1736
|
+
refreshed
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
/** The mailbox, as a person would recognise it. */
|
|
1740
|
+
function accountOf$1(emailAddress) {
|
|
1741
|
+
return {
|
|
1742
|
+
id: emailAddress,
|
|
1743
|
+
label: emailAddress
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Which message this task is about. The poll keeps the id, and a pasted link
|
|
1748
|
+
* carries one too, so either is enough to fetch the mail again.
|
|
1749
|
+
*/
|
|
1750
|
+
function messageIdFrom(url, payload) {
|
|
1751
|
+
const kept = payload ?? {};
|
|
1752
|
+
if (typeof kept.messageId === "string" && kept.messageId) return kept.messageId;
|
|
1753
|
+
const fromUrl = /[#/]([A-Za-z0-9_-]{8,})$/.exec(url.trim());
|
|
1754
|
+
if (fromUrl) return fromUrl[1];
|
|
1755
|
+
throw new Error("This task has lost the message it was about.");
|
|
1756
|
+
}
|
|
1757
|
+
var gmailRuntime = defineRuntime({
|
|
1758
|
+
async readiness() {
|
|
1759
|
+
if (oauthAppRegistration("gmail")) return { ready: true };
|
|
1760
|
+
return {
|
|
1761
|
+
ready: false,
|
|
1762
|
+
reason: "This build has no Google app registration.",
|
|
1763
|
+
fixHint: "Set LOOPABLE_GMAIL_CLIENT_ID and LOOPABLE_GMAIL_CLIENT_SECRET to use your own OAuth app."
|
|
1764
|
+
};
|
|
1765
|
+
},
|
|
1766
|
+
identifyLink(url) {
|
|
1767
|
+
const trimmed = url.trim();
|
|
1768
|
+
if (!/^https:\/\/mail\.google\.com\//.test(trimmed)) return null;
|
|
1769
|
+
const id = /[#/]([A-Za-z0-9_-]{8,})$/.exec(trimmed);
|
|
1770
|
+
return id ? {
|
|
1771
|
+
kind: "email",
|
|
1772
|
+
ref: mailRef(id[1])
|
|
1773
|
+
} : null;
|
|
1774
|
+
},
|
|
1775
|
+
async resolveWorkItem({ url, payload, credential }) {
|
|
1776
|
+
const { accessToken } = await usableToken(credential);
|
|
1777
|
+
const message = await getMessage(accessToken, messageIdFrom(url, payload));
|
|
1778
|
+
return {
|
|
1779
|
+
kind: "email",
|
|
1780
|
+
ref: mailRef(message.id),
|
|
1781
|
+
title: summarise$1(message),
|
|
1782
|
+
url: permalink(message.id),
|
|
1783
|
+
context: [mailContext(message)]
|
|
1784
|
+
};
|
|
1785
|
+
},
|
|
1786
|
+
async poll({ workflowId, settings, credential }) {
|
|
1787
|
+
const { accessToken } = await usableToken(credential);
|
|
1788
|
+
return { signals: await pollGmail({
|
|
1789
|
+
workflowId,
|
|
1790
|
+
settings,
|
|
1791
|
+
accessToken,
|
|
1792
|
+
emailAddress: (await getProfile(accessToken)).emailAddress
|
|
1793
|
+
}) };
|
|
1794
|
+
},
|
|
1795
|
+
auth: {
|
|
1796
|
+
async startAuthorization({ redirectUri, state }) {
|
|
1797
|
+
const registration = requireRegistration();
|
|
1798
|
+
const { verifier, challenge } = createPkce();
|
|
1799
|
+
return {
|
|
1800
|
+
verifier,
|
|
1801
|
+
redirectUrl: buildAuthorizeUrl({
|
|
1802
|
+
clientId: registration.clientId,
|
|
1803
|
+
redirectUri,
|
|
1804
|
+
state,
|
|
1805
|
+
challenge
|
|
1806
|
+
})
|
|
1807
|
+
};
|
|
1808
|
+
},
|
|
1809
|
+
async completeAuthorization({ code, redirectUri, verifier }) {
|
|
1810
|
+
const registration = requireRegistration();
|
|
1811
|
+
const credential = await exchangeCode({
|
|
1812
|
+
clientId: registration.clientId,
|
|
1813
|
+
clientSecret: registration.clientSecret,
|
|
1814
|
+
code,
|
|
1815
|
+
redirectUri,
|
|
1816
|
+
verifier
|
|
1817
|
+
});
|
|
1818
|
+
return {
|
|
1819
|
+
credential,
|
|
1820
|
+
account: accountOf$1((await getProfile(credential.accessToken)).emailAddress)
|
|
1821
|
+
};
|
|
1822
|
+
},
|
|
1823
|
+
async identity(credential) {
|
|
1824
|
+
const { accessToken, refreshed } = await usableToken(credential);
|
|
1825
|
+
const account = accountOf$1((await getProfile(accessToken)).emailAddress);
|
|
1826
|
+
return refreshed ? {
|
|
1827
|
+
account,
|
|
1828
|
+
renewedCredential: refreshed
|
|
1829
|
+
} : { account };
|
|
1830
|
+
},
|
|
1831
|
+
async revoke(credential) {
|
|
1832
|
+
await revokeCredential(credential);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
});
|
|
1836
|
+
/** How a time is written where a person will read it back. */
|
|
1837
|
+
function readable(when) {
|
|
1838
|
+
return when.toLocaleString(void 0, {
|
|
1839
|
+
weekday: "short",
|
|
1840
|
+
day: "numeric",
|
|
1841
|
+
month: "short",
|
|
1842
|
+
hour: "2-digit",
|
|
1843
|
+
minute: "2-digit"
|
|
1844
|
+
});
|
|
1845
|
+
}
|
|
1846
|
+
function signalFor(due) {
|
|
1847
|
+
return {
|
|
1848
|
+
kind: "occurrence",
|
|
1849
|
+
key: due.toISOString(),
|
|
1850
|
+
ref: readable(due),
|
|
1851
|
+
title: `Due ${readable(due)}`,
|
|
1852
|
+
url: "",
|
|
1853
|
+
payload: { due: due.toISOString() }
|
|
1854
|
+
};
|
|
1855
|
+
}
|
|
1856
|
+
var scheduleRuntime = {
|
|
1857
|
+
/** Nothing to register and nothing to install: a clock is always ready. */
|
|
1858
|
+
async readiness() {
|
|
1859
|
+
return { ready: true };
|
|
1860
|
+
},
|
|
1861
|
+
/**
|
|
1862
|
+
* There is no account, so none of this is reachable. `identity` is required
|
|
1863
|
+
* of every connector because every other one has a stored credential to
|
|
1864
|
+
* prove still works, and this one has nothing to prove.
|
|
1865
|
+
*/
|
|
1866
|
+
auth: { async identity() {
|
|
1867
|
+
throw new Error("A schedule has no account.");
|
|
1868
|
+
} },
|
|
1869
|
+
/**
|
|
1870
|
+
* Whether a time has come round since the last one that did.
|
|
1871
|
+
*
|
|
1872
|
+
* The cursor is the occurrence this loop last fired, and it moves only when
|
|
1873
|
+
* one fires. On a first look there is nothing to compare against, so the
|
|
1874
|
+
* cursor is set to now and nothing is returned: turning a loop on should not
|
|
1875
|
+
* immediately run this morning's, which is what would happen if the last
|
|
1876
|
+
* occurrence counted as owed.
|
|
1877
|
+
*/
|
|
1878
|
+
async poll({ settings, cursor }) {
|
|
1879
|
+
const schedule = scheduleFrom(settings);
|
|
1880
|
+
const now = /* @__PURE__ */ new Date();
|
|
1881
|
+
const since = typeof cursor === "string" ? new Date(cursor) : null;
|
|
1882
|
+
if (!since || Number.isNaN(since.getTime())) return {
|
|
1883
|
+
signals: [],
|
|
1884
|
+
cursor: now.toISOString()
|
|
1885
|
+
};
|
|
1886
|
+
const due = dueSince(schedule, since, now);
|
|
1887
|
+
if (!due) return {
|
|
1888
|
+
signals: [],
|
|
1889
|
+
cursor: cursor ?? void 0
|
|
1890
|
+
};
|
|
1891
|
+
return {
|
|
1892
|
+
signals: [signalFor(due)],
|
|
1893
|
+
cursor: due.toISOString()
|
|
1894
|
+
};
|
|
1895
|
+
},
|
|
1896
|
+
/**
|
|
1897
|
+
* A run somebody asked for is about the moment they asked, not about the
|
|
1898
|
+
* last time the clock came round. Waiting until nine tomorrow to find out
|
|
1899
|
+
* whether a loop works is no way to shape one.
|
|
1900
|
+
*/
|
|
1901
|
+
itemForNow() {
|
|
1902
|
+
return {
|
|
1903
|
+
kind: "occurrence",
|
|
1904
|
+
ref: readable(/* @__PURE__ */ new Date())
|
|
1905
|
+
};
|
|
1906
|
+
},
|
|
1907
|
+
/**
|
|
1908
|
+
* Writes nothing anywhere.
|
|
1909
|
+
*
|
|
1910
|
+
* Which is worth having as an action rather than as a gap, because a loop
|
|
1911
|
+
* needs somewhere for its answer to go before it will run at all, and until
|
|
1912
|
+
* a reliable destination exists this is the honest one: every run is already
|
|
1913
|
+
* recorded, so the answer is kept where a person will look for it. Returns
|
|
1914
|
+
* no address because the task's own page is the address.
|
|
1915
|
+
*/
|
|
1916
|
+
async applyAction({ actionId }) {
|
|
1917
|
+
if (actionId !== "schedule.record") throw new Error(`A schedule cannot ${actionId}.`);
|
|
1918
|
+
return { url: "" };
|
|
1919
|
+
},
|
|
1920
|
+
/**
|
|
1921
|
+
* There is nothing to fetch: the occurrence is the whole of what happened,
|
|
1922
|
+
* and the poll already wrote it down. Anything run from the button rather
|
|
1923
|
+
* than by the clock has no occurrence behind it and is about now.
|
|
1924
|
+
*/
|
|
1925
|
+
async resolveWorkItem({ payload }) {
|
|
1926
|
+
return {
|
|
1927
|
+
...signalFor(payload && typeof payload === "object" && "due" in payload && typeof payload.due === "string" ? new Date(payload.due) : /* @__PURE__ */ new Date()),
|
|
1928
|
+
context: []
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
};
|
|
1932
|
+
/**
|
|
1933
|
+
* Tencent's iLink bot API, as documented for clients that are not OpenClaw:
|
|
1934
|
+
* https://github.com/Tencent/openclaw-weixin/blob/main/docs/protocol.md
|
|
1935
|
+
*
|
|
1936
|
+
* The document describes what Tencent's own client does rather than promising
|
|
1937
|
+
* a server contract, so anything not observed here is treated as optional and
|
|
1938
|
+
* a surprise is reported rather than assumed away.
|
|
1939
|
+
*/
|
|
1940
|
+
var WECHAT_API = "https://ilinkai.weixin.qq.com";
|
|
1941
|
+
/**
|
|
1942
|
+
* The version of the protocol we speak, not of Loopable. It is the plugin
|
|
1943
|
+
* release whose documented behaviour this client was written against, encoded
|
|
1944
|
+
* as 0x00MMNNPP and sent as decimal.
|
|
1945
|
+
*/
|
|
1946
|
+
var PROTOCOL = {
|
|
1947
|
+
major: 2,
|
|
1948
|
+
minor: 4,
|
|
1949
|
+
patch: 8
|
|
1950
|
+
};
|
|
1951
|
+
var CLIENT_VERSION = String(PROTOCOL.major << 16 | PROTOCOL.minor << 8 | PROTOCOL.patch);
|
|
1952
|
+
/**
|
|
1953
|
+
* Who is calling, in the slot Tencent left for clients other than their own.
|
|
1954
|
+
* Sent for their observability, not for auth, and worth being honest in.
|
|
1955
|
+
*/
|
|
1956
|
+
var BASE_INFO = {
|
|
1957
|
+
channel_version: `${PROTOCOL.major}.${PROTOCOL.minor}.${PROTOCOL.patch}`,
|
|
1958
|
+
bot_agent: "Loopable/0.1"
|
|
1959
|
+
};
|
|
1960
|
+
var APP_HEADERS = {
|
|
1961
|
+
"iLink-App-Id": "bot",
|
|
1962
|
+
"iLink-App-ClientVersion": CLIENT_VERSION
|
|
1963
|
+
};
|
|
1964
|
+
/** "Base64-encoded decimal representation of a random uint32." */
|
|
1965
|
+
function uin() {
|
|
1966
|
+
return Buffer.from(String(Math.floor(Math.random() * 4294967295))).toString("base64");
|
|
1967
|
+
}
|
|
1968
|
+
function postHeaders(token) {
|
|
1969
|
+
return {
|
|
1970
|
+
"Content-Type": "application/json",
|
|
1971
|
+
AuthorizationType: "ilink_bot_token",
|
|
1972
|
+
"X-WECHAT-UIN": uin(),
|
|
1973
|
+
...APP_HEADERS,
|
|
1974
|
+
...token ? { Authorization: `Bearer ${token}` } : {}
|
|
1975
|
+
};
|
|
1976
|
+
}
|
|
1977
|
+
/**
|
|
1978
|
+
* WeChat's own trouble is not ours to fail a loop over. A refused request is
|
|
1979
|
+
* an answer; an unreachable host or a 5xx is a bad minute.
|
|
1980
|
+
*/
|
|
1981
|
+
async function call(url, init) {
|
|
1982
|
+
let response;
|
|
1983
|
+
try {
|
|
1984
|
+
response = await fetch(url, init);
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
throw new TransientError(`WeChat is unreachable: ${error.message}`);
|
|
1987
|
+
}
|
|
1988
|
+
if (response.status >= 500 || response.status === 429) throw new TransientError(`WeChat answered ${response.status}`);
|
|
1989
|
+
if (!response.ok) throw new Error(`WeChat answered ${response.status}`);
|
|
1990
|
+
return await response.json();
|
|
1991
|
+
}
|
|
1992
|
+
async function requestQrCode() {
|
|
1993
|
+
const body = await call(`${WECHAT_API}/ilink/bot/get_bot_qrcode?bot_type=3`, {
|
|
1994
|
+
method: "POST",
|
|
1995
|
+
headers: postHeaders(),
|
|
1996
|
+
body: JSON.stringify({ local_token_list: [] })
|
|
1997
|
+
});
|
|
1998
|
+
if (!body.qrcode || !body.qrcode_img_content) throw new Error("WeChat did not return a login code.");
|
|
1999
|
+
return {
|
|
2000
|
+
qrcode: body.qrcode,
|
|
2001
|
+
url: body.qrcode_img_content
|
|
2002
|
+
};
|
|
2003
|
+
}
|
|
2004
|
+
function absolute(host) {
|
|
2005
|
+
return host.startsWith("http") ? host : `https://${host}`;
|
|
2006
|
+
}
|
|
2007
|
+
/**
|
|
2008
|
+
* One look at a pending scan. Everything except a finished login is a reason
|
|
2009
|
+
* to look again, including a failed request: a login in progress should not be
|
|
2010
|
+
* thrown away over one bad response.
|
|
2011
|
+
*/
|
|
2012
|
+
async function qrCodeStatus(qrcode, host) {
|
|
2013
|
+
let body;
|
|
2014
|
+
try {
|
|
2015
|
+
body = await call(`${absolute(host)}/ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`, { headers: APP_HEADERS });
|
|
2016
|
+
} catch {
|
|
2017
|
+
return {
|
|
2018
|
+
state: "waiting",
|
|
2019
|
+
host
|
|
2020
|
+
};
|
|
2021
|
+
}
|
|
2022
|
+
switch (body.status) {
|
|
2023
|
+
case "confirmed":
|
|
2024
|
+
if (!body.bot_token) return {
|
|
2025
|
+
state: "expired",
|
|
2026
|
+
reason: "WeChat confirmed without a token."
|
|
2027
|
+
};
|
|
2028
|
+
return {
|
|
2029
|
+
state: "confirmed",
|
|
2030
|
+
credential: {
|
|
2031
|
+
botToken: body.bot_token,
|
|
2032
|
+
botId: body.ilink_bot_id ?? "",
|
|
2033
|
+
userId: body.ilink_user_id ?? "",
|
|
2034
|
+
baseUrl: body.baseurl ? absolute(body.baseurl) : WECHAT_API
|
|
2035
|
+
}
|
|
2036
|
+
};
|
|
2037
|
+
case "scaned": return {
|
|
2038
|
+
state: "waiting",
|
|
2039
|
+
host,
|
|
2040
|
+
hint: "Scanned. Confirm it on your phone."
|
|
2041
|
+
};
|
|
2042
|
+
case "scaned_but_redirect": return {
|
|
2043
|
+
state: "waiting",
|
|
2044
|
+
host: body.redirect_host ? absolute(body.redirect_host) : host,
|
|
2045
|
+
hint: "Scanned. Confirm it on your phone."
|
|
2046
|
+
};
|
|
2047
|
+
case "need_verifycode": return {
|
|
2048
|
+
state: "waiting",
|
|
2049
|
+
host,
|
|
2050
|
+
hint: "WeChat is asking for a verification code."
|
|
2051
|
+
};
|
|
2052
|
+
case "binded_redirect": return {
|
|
2053
|
+
state: "expired",
|
|
2054
|
+
reason: "This account is already bound somewhere else."
|
|
2055
|
+
};
|
|
2056
|
+
case "verify_code_blocked": return {
|
|
2057
|
+
state: "expired",
|
|
2058
|
+
reason: "Too many verification attempts. Start again."
|
|
2059
|
+
};
|
|
2060
|
+
case "expired": return { state: "expired" };
|
|
2061
|
+
default: return {
|
|
2062
|
+
state: "waiting",
|
|
2063
|
+
host
|
|
2064
|
+
};
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
/** A dead token, as opposed to a request WeChat merely disliked. */
|
|
2068
|
+
var SESSION_GONE = -14;
|
|
2069
|
+
/**
|
|
2070
|
+
* Whether WeChat refused, and why.
|
|
2071
|
+
*
|
|
2072
|
+
* It reports trouble in two fields and not always the same one: a call with a
|
|
2073
|
+
* dead token comes back `{errcode:-14}` with no `ret` at all, so a check that
|
|
2074
|
+
* reads only `ret` would call that a success and let a signed-out account look
|
|
2075
|
+
* connected.
|
|
2076
|
+
*/
|
|
2077
|
+
function signedOut(body) {
|
|
2078
|
+
return body.ret === SESSION_GONE || body.errcode === SESSION_GONE;
|
|
2079
|
+
}
|
|
2080
|
+
function refusal(body) {
|
|
2081
|
+
const bad = [body.ret, body.errcode].filter((code) => typeof code === "number").find((code) => code !== 0);
|
|
2082
|
+
if (bad === void 0) return null;
|
|
2083
|
+
if (bad === SESSION_GONE) return "WeChat has signed this bot out. Connect again by scanning.";
|
|
2084
|
+
return body.errmsg || `WeChat refused the request (${bad}).`;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* Tells WeChat a client is up, and doubles as the proof that a stored token
|
|
2088
|
+
* still works: it needs nothing but the token, and a dead one is refused.
|
|
2089
|
+
*
|
|
2090
|
+
* Reading the account's config would be the obvious check and is the wrong
|
|
2091
|
+
* one. It mints a typing ticket, which needs a conversation, so it fails on
|
|
2092
|
+
* exactly the account that has just been connected and never messaged.
|
|
2093
|
+
*/
|
|
2094
|
+
async function notifyStart(credential) {
|
|
2095
|
+
const refused = refusal(await call(`${credential.baseUrl}/ilink/bot/msg/notifystart`, {
|
|
2096
|
+
method: "POST",
|
|
2097
|
+
headers: postHeaders(credential.botToken),
|
|
2098
|
+
body: JSON.stringify({ base_info: BASE_INFO })
|
|
2099
|
+
}));
|
|
2100
|
+
if (refused) throw new Error(refused);
|
|
2101
|
+
}
|
|
2102
|
+
/** `1` is a person typing at us. `2` is the bot, which is to say ourselves. */
|
|
2103
|
+
var FROM_USER = 1;
|
|
2104
|
+
var TEXT_ITEM = 1;
|
|
2105
|
+
/** What someone actually typed, with the parts we cannot read left out. */
|
|
2106
|
+
function textOf(message) {
|
|
2107
|
+
return (message.item_list ?? []).filter((item) => item.type === TEXT_ITEM).map((item) => item.text_item?.text?.trim()).filter((text) => Boolean(text)).join("\n").trim();
|
|
2108
|
+
}
|
|
2109
|
+
function isFromPerson(message) {
|
|
2110
|
+
return message.message_type === FROM_USER;
|
|
2111
|
+
}
|
|
2112
|
+
/**
|
|
2113
|
+
* How long to hold the connection open waiting for someone to type. This is a
|
|
2114
|
+
* long poll and the server decides when to answer, so the limit is ours: the
|
|
2115
|
+
* caller is a loop with other loops to get to, and an empty answer costs it a
|
|
2116
|
+
* turn rather than the whole minute.
|
|
2117
|
+
*/
|
|
2118
|
+
var WAIT_MS = 1e4;
|
|
2119
|
+
/**
|
|
2120
|
+
* Messages since the last call, and the cursor to hand back on the next one.
|
|
2121
|
+
*
|
|
2122
|
+
* Unlike everything else Loopable polls, this is a stream: what it returns, it
|
|
2123
|
+
* will not return again. Nothing is lost by giving up on a slow request, since
|
|
2124
|
+
* the cursor only moves when an answer arrives, but an answer that is thrown
|
|
2125
|
+
* away takes its messages with it.
|
|
2126
|
+
*/
|
|
2127
|
+
async function getUpdates(credential, cursor) {
|
|
2128
|
+
const abort = new AbortController();
|
|
2129
|
+
const timer = setTimeout(() => abort.abort(), WAIT_MS);
|
|
2130
|
+
let body;
|
|
2131
|
+
try {
|
|
2132
|
+
body = await call(`${credential.baseUrl}/ilink/bot/getupdates`, {
|
|
2133
|
+
method: "POST",
|
|
2134
|
+
headers: postHeaders(credential.botToken),
|
|
2135
|
+
body: JSON.stringify({
|
|
2136
|
+
get_updates_buf: cursor,
|
|
2137
|
+
base_info: BASE_INFO
|
|
2138
|
+
}),
|
|
2139
|
+
signal: abort.signal
|
|
2140
|
+
});
|
|
2141
|
+
} catch (error) {
|
|
2142
|
+
if (abort.signal.aborted) return {
|
|
2143
|
+
messages: [],
|
|
2144
|
+
cursor
|
|
2145
|
+
};
|
|
2146
|
+
throw error;
|
|
2147
|
+
} finally {
|
|
2148
|
+
clearTimeout(timer);
|
|
2149
|
+
}
|
|
2150
|
+
const refused = refusal(body);
|
|
2151
|
+
if (refused) throw new Error(refused);
|
|
2152
|
+
return {
|
|
2153
|
+
messages: body.msgs ?? [],
|
|
2154
|
+
cursor: body.get_updates_buf || cursor
|
|
2155
|
+
};
|
|
2156
|
+
}
|
|
2157
|
+
/**
|
|
2158
|
+
* Replies to whoever sent `contextToken`.
|
|
2159
|
+
*
|
|
2160
|
+
* The token comes off the message being answered and cannot be invented: it is
|
|
2161
|
+
* what tells WeChat which conversation this belongs to, and it goes stale
|
|
2162
|
+
* after a couple of days, which is why a reply is worth queueing but not worth
|
|
2163
|
+
* retrying for a week. `clientId` is what stops a retry saying it twice.
|
|
2164
|
+
*/
|
|
2165
|
+
async function sendMessage(credential, input) {
|
|
2166
|
+
const body = await call(`${credential.baseUrl}/ilink/bot/sendmessage`, {
|
|
2167
|
+
method: "POST",
|
|
2168
|
+
headers: postHeaders(credential.botToken),
|
|
2169
|
+
body: JSON.stringify({
|
|
2170
|
+
msg: {
|
|
2171
|
+
from_user_id: "",
|
|
2172
|
+
to_user_id: input.toUserId,
|
|
2173
|
+
client_id: input.clientId,
|
|
2174
|
+
message_type: 2,
|
|
2175
|
+
message_state: 2,
|
|
2176
|
+
...input.contextToken ? { context_token: input.contextToken } : {},
|
|
2177
|
+
item_list: [{
|
|
2178
|
+
type: TEXT_ITEM,
|
|
2179
|
+
text_item: { text: input.text }
|
|
2180
|
+
}]
|
|
2181
|
+
},
|
|
2182
|
+
base_info: BASE_INFO
|
|
2183
|
+
})
|
|
2184
|
+
});
|
|
2185
|
+
const refused = refusal(body);
|
|
2186
|
+
if (!refused) return;
|
|
2187
|
+
if (!input.contextToken && !signedOut(body)) throw new Error(`WeChat would not take a message nobody asked for (it said: ${refused}). It only delivers those for about half a day after you last write to the bot, so send the bot anything and this will go through.`);
|
|
2188
|
+
throw new Error(refused);
|
|
2189
|
+
}
|
|
2190
|
+
/**
|
|
2191
|
+
* A login code lasted about two minutes when measured against the live API.
|
|
2192
|
+
* Told to the page so it can offer a fresh one rather than leave a dead square
|
|
2193
|
+
* on screen.
|
|
2194
|
+
*/
|
|
2195
|
+
var CODE_LIFETIME_MS = 11e4;
|
|
2196
|
+
function asAttempt(value) {
|
|
2197
|
+
const record = value;
|
|
2198
|
+
if (!record || typeof record.qrcode !== "string" || typeof record.host !== "string") throw new Error("This login is no longer valid. Ask for a new code.");
|
|
2199
|
+
return {
|
|
2200
|
+
qrcode: record.qrcode,
|
|
2201
|
+
host: record.host
|
|
2202
|
+
};
|
|
2203
|
+
}
|
|
2204
|
+
function asAsked(value) {
|
|
2205
|
+
const record = value;
|
|
2206
|
+
if (!record || typeof record.fromUserId !== "string" || typeof record.text !== "string") throw new Error("This task has lost the message it was about.");
|
|
2207
|
+
return {
|
|
2208
|
+
messageId: Number(record.messageId ?? 0),
|
|
2209
|
+
fromUserId: record.fromUserId,
|
|
2210
|
+
contextToken: typeof record.contextToken === "string" ? record.contextToken : void 0,
|
|
2211
|
+
text: record.text
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
/** Enough of the message to recognise it in a list. */
|
|
2215
|
+
function summarise(text) {
|
|
2216
|
+
const line = text.split("\n")[0].trim();
|
|
2217
|
+
return line.length > 80 ? `${line.slice(0, 79)}…` : line;
|
|
2218
|
+
}
|
|
2219
|
+
/**
|
|
2220
|
+
* WeChat takes a message at a time and long ones are refused, so an answer
|
|
2221
|
+
* that runs on is sent in pieces rather than truncated.
|
|
2222
|
+
*/
|
|
2223
|
+
var CHUNK = 1500;
|
|
2224
|
+
function chunked(text) {
|
|
2225
|
+
const parts = [];
|
|
2226
|
+
let rest = text.trim();
|
|
2227
|
+
while (rest.length > CHUNK) {
|
|
2228
|
+
const window = rest.slice(0, CHUNK);
|
|
2229
|
+
const cut = Math.max(window.lastIndexOf("\n\n"), window.lastIndexOf("\n"));
|
|
2230
|
+
const at = cut > CHUNK / 2 ? cut : CHUNK;
|
|
2231
|
+
parts.push(rest.slice(0, at).trim());
|
|
2232
|
+
rest = rest.slice(at).trim();
|
|
2233
|
+
}
|
|
2234
|
+
if (rest) parts.push(rest);
|
|
2235
|
+
return parts;
|
|
2236
|
+
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Who the reply goes to, with the token that says which conversation it
|
|
2239
|
+
* belongs to when there is one.
|
|
2240
|
+
*
|
|
2241
|
+
* Answering whoever asked has that token off the message itself, and is the
|
|
2242
|
+
* case WeChat supports. Telling yourself about something that happened
|
|
2243
|
+
* elsewhere has no message behind it and so no token; that is sent anyway,
|
|
2244
|
+
* because refusing it would mean this cannot be offered at all, and the
|
|
2245
|
+
* setting says as much where it is chosen.
|
|
2246
|
+
*/
|
|
2247
|
+
function recipientFor(target, source, account) {
|
|
2248
|
+
if (target.to === "me") return { userId: account.userId };
|
|
2249
|
+
if (source.connectorId !== "wechat") throw new Error("There is no chat behind this task. Choose who to send it to on the loop.");
|
|
2250
|
+
const carry = source.carry ?? {};
|
|
2251
|
+
if (!carry.toUserId) throw new Error("There is nobody to reply to on this task.");
|
|
2252
|
+
return {
|
|
2253
|
+
userId: carry.toUserId,
|
|
2254
|
+
contextToken: carry.contextToken
|
|
2255
|
+
};
|
|
2256
|
+
}
|
|
2257
|
+
/** The account, said the way a person would recognise it. */
|
|
2258
|
+
function accountOf(credential) {
|
|
2259
|
+
return {
|
|
2260
|
+
id: credential.botId || credential.userId,
|
|
2261
|
+
label: credential.botId ? `WeChat bot ${credential.botId}` : "WeChat bot"
|
|
2262
|
+
};
|
|
2263
|
+
}
|
|
2264
|
+
/** Server-only registry. Never import this from a component. */
|
|
2265
|
+
var RUNTIMES = {
|
|
2266
|
+
github: githubRuntime,
|
|
2267
|
+
gmail: gmailRuntime,
|
|
2268
|
+
wechat: {
|
|
2269
|
+
async readiness() {
|
|
2270
|
+
return { ready: true };
|
|
2271
|
+
},
|
|
2272
|
+
/**
|
|
2273
|
+
* A stream, not a search: what this returns has already been taken off the
|
|
2274
|
+
* server and will not be offered again, which is why every message it keeps
|
|
2275
|
+
* carries its whole content rather than a pointer to it.
|
|
2276
|
+
*/
|
|
2277
|
+
async poll({ workflowId, settings, credential, cursor }) {
|
|
2278
|
+
if (workflowId !== "wechat.ask") throw new Error(`WeChat cannot watch for ${workflowId}.`);
|
|
2279
|
+
const account = credential;
|
|
2280
|
+
const { messages, cursor: next } = await getUpdates(account, typeof cursor === "string" ? cursor : "");
|
|
2281
|
+
const onlyMe = (settings.askers ?? "me") !== "anyone";
|
|
2282
|
+
const signals = [];
|
|
2283
|
+
for (const message of messages) {
|
|
2284
|
+
if (!isFromPerson(message)) continue;
|
|
2285
|
+
const text = textOf(message);
|
|
2286
|
+
if (!text) continue;
|
|
2287
|
+
const fromUserId = message.from_user_id ?? "";
|
|
2288
|
+
const asked = {
|
|
2289
|
+
messageId: message.message_id ?? 0,
|
|
2290
|
+
fromUserId,
|
|
2291
|
+
contextToken: message.context_token,
|
|
2292
|
+
text
|
|
2293
|
+
};
|
|
2294
|
+
signals.push({
|
|
2295
|
+
key: `msg#${asked.messageId}`,
|
|
2296
|
+
kind: "message",
|
|
2297
|
+
ref: `msg#${asked.messageId}`,
|
|
2298
|
+
title: summarise(text),
|
|
2299
|
+
url: "",
|
|
2300
|
+
hold: onlyMe && fromUserId !== account.userId ? "sent by someone other than you" : void 0,
|
|
2301
|
+
payload: asked
|
|
2302
|
+
});
|
|
2303
|
+
}
|
|
2304
|
+
return {
|
|
2305
|
+
signals,
|
|
2306
|
+
cursor: next
|
|
2307
|
+
};
|
|
2308
|
+
},
|
|
2309
|
+
async resolveWorkItem({ payload }) {
|
|
2310
|
+
const asked = asAsked(payload);
|
|
2311
|
+
return {
|
|
2312
|
+
kind: "message",
|
|
2313
|
+
ref: `msg#${asked.messageId}`,
|
|
2314
|
+
title: summarise(asked.text),
|
|
2315
|
+
url: "",
|
|
2316
|
+
carry: {
|
|
2317
|
+
toUserId: asked.fromUserId,
|
|
2318
|
+
contextToken: asked.contextToken
|
|
2319
|
+
},
|
|
2320
|
+
context: [{
|
|
2321
|
+
name: "MESSAGE.md",
|
|
2322
|
+
body: [
|
|
2323
|
+
`# What you were asked`,
|
|
2324
|
+
``,
|
|
2325
|
+
asked.text,
|
|
2326
|
+
``
|
|
2327
|
+
].join("\n")
|
|
2328
|
+
}]
|
|
2329
|
+
};
|
|
2330
|
+
},
|
|
2331
|
+
async applyAction({ actionId, target, source, body, credential }) {
|
|
2332
|
+
if (actionId !== "wechat.reply") throw new Error(`WeChat cannot ${actionId}.`);
|
|
2333
|
+
const account = credential;
|
|
2334
|
+
const to = recipientFor(target, source, account);
|
|
2335
|
+
for (const part of chunked(body)) await sendMessage(account, {
|
|
2336
|
+
toUserId: to.userId,
|
|
2337
|
+
contextToken: to.contextToken,
|
|
2338
|
+
clientId: randomUUID(),
|
|
2339
|
+
text: part
|
|
2340
|
+
});
|
|
2341
|
+
return { url: "" };
|
|
2342
|
+
},
|
|
2343
|
+
auth: {
|
|
2344
|
+
async startQrLogin() {
|
|
2345
|
+
const code = await requestQrCode();
|
|
2346
|
+
return {
|
|
2347
|
+
attempt: {
|
|
2348
|
+
qrcode: code.qrcode,
|
|
2349
|
+
host: WECHAT_API
|
|
2350
|
+
},
|
|
2351
|
+
encode: code.url,
|
|
2352
|
+
expiresInMs: CODE_LIFETIME_MS
|
|
2353
|
+
};
|
|
2354
|
+
},
|
|
2355
|
+
async pollQrLogin(value) {
|
|
2356
|
+
const attempt = asAttempt(value);
|
|
2357
|
+
const status = await qrCodeStatus(attempt.qrcode, attempt.host);
|
|
2358
|
+
switch (status.state) {
|
|
2359
|
+
case "confirmed": return {
|
|
2360
|
+
state: "confirmed",
|
|
2361
|
+
result: {
|
|
2362
|
+
credential: status.credential,
|
|
2363
|
+
account: accountOf(status.credential)
|
|
2364
|
+
}
|
|
2365
|
+
};
|
|
2366
|
+
case "expired": return {
|
|
2367
|
+
state: "expired",
|
|
2368
|
+
reason: status.reason
|
|
2369
|
+
};
|
|
2370
|
+
case "waiting": return {
|
|
2371
|
+
state: "pending",
|
|
2372
|
+
attempt: {
|
|
2373
|
+
qrcode: attempt.qrcode,
|
|
2374
|
+
host: status.host
|
|
2375
|
+
},
|
|
2376
|
+
hint: status.hint
|
|
2377
|
+
};
|
|
2378
|
+
}
|
|
2379
|
+
},
|
|
2380
|
+
async identity(credential) {
|
|
2381
|
+
const stored = credential;
|
|
2382
|
+
await notifyStart(stored);
|
|
2383
|
+
return { account: accountOf(stored) };
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
},
|
|
2387
|
+
schedule: scheduleRuntime
|
|
2388
|
+
};
|
|
2389
|
+
function connectorRuntime(id) {
|
|
2390
|
+
const runtime = RUNTIMES[id];
|
|
2391
|
+
if (!runtime) throw new Error(`Unknown connector: ${id}`);
|
|
2392
|
+
return runtime;
|
|
2393
|
+
}
|
|
2394
|
+
var ATTEMPT_TTL_MS = 6e5;
|
|
2395
|
+
function toView(row) {
|
|
2396
|
+
return {
|
|
2397
|
+
id: row.id,
|
|
2398
|
+
connectorId: row.connectorId,
|
|
2399
|
+
status: row.status,
|
|
2400
|
+
accountLabel: row.accountLabel,
|
|
2401
|
+
accountUrl: row.accountUrl,
|
|
2402
|
+
avatarUrl: row.avatarUrl,
|
|
2403
|
+
scopes: row.scopes ? row.scopes.split(",").filter(Boolean) : [],
|
|
2404
|
+
settings: row.settings,
|
|
2405
|
+
lastSyncedAt: row.lastSyncedAt?.toISOString() ?? null,
|
|
2406
|
+
lastError: row.lastError,
|
|
2407
|
+
createdAt: row.createdAt.toISOString()
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
async function listConnections() {
|
|
2411
|
+
return (await db().select().from(connections).orderBy(asc(connections.createdAt)).all()).map(toView);
|
|
2412
|
+
}
|
|
2413
|
+
async function getConnection(id) {
|
|
2414
|
+
return await db().select().from(connections).where(eq(connections.id, id)).get();
|
|
2415
|
+
}
|
|
2416
|
+
async function startAuthAttempt(input) {
|
|
2417
|
+
const now = Date.now();
|
|
2418
|
+
await db().delete(authAttempts).where(lt(authAttempts.expiresAt, new Date(now))).run();
|
|
2419
|
+
await db().insert(authAttempts).values({
|
|
2420
|
+
state: input.state,
|
|
2421
|
+
connectorId: input.connectorId,
|
|
2422
|
+
verifier: input.verifier,
|
|
2423
|
+
redirectUri: input.redirectUri,
|
|
2424
|
+
returnTo: input.returnTo,
|
|
2425
|
+
expiresAt: new Date(now + ATTEMPT_TTL_MS)
|
|
2426
|
+
}).run();
|
|
2427
|
+
}
|
|
2428
|
+
/** Consumes the attempt: a state value is only ever valid once. */
|
|
2429
|
+
async function takeAuthAttempt(state) {
|
|
2430
|
+
const attempt = await db().select().from(authAttempts).where(eq(authAttempts.state, state)).get();
|
|
2431
|
+
if (attempt) await db().delete(authAttempts).where(eq(authAttempts.state, state)).run();
|
|
2432
|
+
if (!attempt) throw new Error("This authorization link is no longer valid. Start again.");
|
|
2433
|
+
if (attempt.expiresAt.getTime() < Date.now()) throw new Error("The authorization took too long. Start again.");
|
|
2434
|
+
return attempt;
|
|
2435
|
+
}
|
|
2436
|
+
function scopesToColumn(account) {
|
|
2437
|
+
return account.scopes?.length ? account.scopes.join(",") : null;
|
|
2438
|
+
}
|
|
2439
|
+
/**
|
|
2440
|
+
* Stores the result of a successful authorization. Re-authorizing the same
|
|
2441
|
+
* account updates the existing connection instead of creating a duplicate.
|
|
2442
|
+
*/
|
|
2443
|
+
async function saveAuthorizedConnection(connectorId, result) {
|
|
2444
|
+
const existing = await db().select().from(connections).where(and(eq(connections.connectorId, connectorId), eq(connections.accountId, result.account.id))).get();
|
|
2445
|
+
const id = existing?.id ?? randomUUID();
|
|
2446
|
+
await writeCredential(id, result.credential);
|
|
2447
|
+
const values = {
|
|
2448
|
+
connectorId,
|
|
2449
|
+
status: "connected",
|
|
2450
|
+
accountId: result.account.id,
|
|
2451
|
+
accountLabel: result.account.label,
|
|
2452
|
+
accountUrl: result.account.url ?? null,
|
|
2453
|
+
avatarUrl: result.account.avatarUrl ?? null,
|
|
2454
|
+
scopes: scopesToColumn(result.account),
|
|
2455
|
+
lastError: null,
|
|
2456
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2457
|
+
};
|
|
2458
|
+
if (existing) await db().update(connections).set(values).where(eq(connections.id, id)).run();
|
|
2459
|
+
else await db().insert(connections).values({
|
|
2460
|
+
id,
|
|
2461
|
+
...values
|
|
2462
|
+
}).run();
|
|
2463
|
+
const row = await getConnection(id);
|
|
2464
|
+
if (!row) throw new Error("Failed to persist the connection");
|
|
2465
|
+
return toView(row);
|
|
2466
|
+
}
|
|
2467
|
+
async function removeConnection(id) {
|
|
2468
|
+
const row = await getConnection(id);
|
|
2469
|
+
if (!row) return;
|
|
2470
|
+
const runtime = connectorRuntime(row.connectorId);
|
|
2471
|
+
if (runtime.auth.revoke) {
|
|
2472
|
+
const credential = await readCredential(id);
|
|
2473
|
+
if (credential) try {
|
|
2474
|
+
await runtime.auth.revoke(credential);
|
|
2475
|
+
} catch {}
|
|
2476
|
+
}
|
|
2477
|
+
await deleteCredential(id);
|
|
2478
|
+
await db().delete(connections).where(eq(connections.id, id)).run();
|
|
2479
|
+
}
|
|
2480
|
+
async function updateConnectionSettings(id, settings) {
|
|
2481
|
+
await db().update(connections).set({
|
|
2482
|
+
settings,
|
|
2483
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2484
|
+
}).where(eq(connections.id, id)).run();
|
|
2485
|
+
const row = await getConnection(id);
|
|
2486
|
+
if (!row) throw new Error("Connection not found");
|
|
2487
|
+
return toView(row);
|
|
2488
|
+
}
|
|
2489
|
+
/**
|
|
2490
|
+
* A credential a run can use right now. Goes through the connection check so a
|
|
2491
|
+
* rotated credential is stored rather than refreshed again on the next run,
|
|
2492
|
+
* and so a broken connection fails here with a clear message instead of a 401
|
|
2493
|
+
* in the middle of the work.
|
|
2494
|
+
*/
|
|
2495
|
+
async function credentialForConnector(connectorId) {
|
|
2496
|
+
if (!needsAccount(connectorId)) return { credential: null };
|
|
2497
|
+
const row = await db().select().from(connections).where(eq(connections.connectorId, connectorId)).orderBy(asc(connections.createdAt)).get();
|
|
2498
|
+
if (!row) throw new Error(`No ${connectorId} account is connected.`);
|
|
2499
|
+
const checked = await checkConnection(row.id);
|
|
2500
|
+
if (checked.status !== "connected") throw new Error(checked.lastError ?? `The ${connectorId} connection needs reconnecting.`);
|
|
2501
|
+
const credential = await readCredential(row.id);
|
|
2502
|
+
if (!credential) throw new Error(`No credential is stored for ${connectorId}.`);
|
|
2503
|
+
return { credential };
|
|
2504
|
+
}
|
|
2505
|
+
/** Proves the stored credential still works and refreshes the shown account. */
|
|
2506
|
+
async function checkConnection(id) {
|
|
2507
|
+
const row = await getConnection(id);
|
|
2508
|
+
if (!row) throw new Error("Connection not found");
|
|
2509
|
+
const runtime = connectorRuntime(row.connectorId);
|
|
2510
|
+
const credential = await readCredential(id);
|
|
2511
|
+
if (!credential) {
|
|
2512
|
+
await db().update(connections).set({
|
|
2513
|
+
status: "needs_reauth",
|
|
2514
|
+
lastError: "No credential is stored for this connection.",
|
|
2515
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2516
|
+
}).where(eq(connections.id, id)).run();
|
|
2517
|
+
return toView(await getConnection(id));
|
|
2518
|
+
}
|
|
2519
|
+
try {
|
|
2520
|
+
const { account, renewedCredential } = await runtime.auth.identity(credential);
|
|
2521
|
+
if (renewedCredential) await writeCredential(id, renewedCredential);
|
|
2522
|
+
await db().update(connections).set({
|
|
2523
|
+
status: "connected",
|
|
2524
|
+
accountLabel: account.label,
|
|
2525
|
+
accountUrl: account.url ?? null,
|
|
2526
|
+
avatarUrl: account.avatarUrl ?? null,
|
|
2527
|
+
scopes: scopesToColumn(account),
|
|
2528
|
+
lastError: null,
|
|
2529
|
+
lastSyncedAt: /* @__PURE__ */ new Date(),
|
|
2530
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2531
|
+
}).where(eq(connections.id, id)).run();
|
|
2532
|
+
} catch (error) {
|
|
2533
|
+
await db().update(connections).set({
|
|
2534
|
+
status: "needs_reauth",
|
|
2535
|
+
lastError: error instanceof Error ? error.message : String(error),
|
|
2536
|
+
updatedAt: /* @__PURE__ */ new Date()
|
|
2537
|
+
}).where(eq(connections.id, id)).run();
|
|
2538
|
+
}
|
|
2539
|
+
return toView(await getConnection(id));
|
|
2540
|
+
}
|
|
2541
|
+
//#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 };
|