meta-sovereign 0.49.2 → 0.49.4
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/js/src/server/aggregate.js +16 -2
- package/js/src/server/github-service.js +383 -0
- package/js/src/server/index.js +13 -9
- package/js/src/server/routes-github.js +98 -0
- package/js/src/server/routes-messages.js +8 -6
- package/js/src/server/routes-mutating.js +15 -32
- package/js/src/server/routes-pull.js +163 -0
- package/js/src/sources/auto-import/files.js +5 -1
- package/js/src/sources/auto-import/recipes.js +18 -1
- package/js/src/sources/github-actions.js +162 -0
- package/js/src/sources/github-http.js +266 -0
- package/js/src/sources/github-inbox.js +312 -0
- package/js/src/sources/github-ref.js +106 -0
- package/js/src/sources/github-sync.js +124 -0
- package/js/src/sources/github.js +79 -193
- package/js/src/sources/pull-runner.js +180 -0
- package/js/src/sources/vk-html-archive.js +268 -0
- package/js/src/sources/vk-limits.js +119 -0
- package/js/src/sources/vk-longpoll.js +333 -0
- package/js/src/sources/vk-sync.js +138 -0
- package/js/src/sources/vk.js +224 -71
- package/js/src/storage/migrations/0003-vk-reactions-docs.js +95 -0
- package/js/src/storage/migrations/0004-github-external-ids.js +115 -0
- package/js/src/storage/migrations/index.js +4 -2
- package/js/src/web/app.css +43 -0
- package/js/src/web/chat-media.js +3 -0
- package/js/src/web/chat-remote-media.js +111 -0
- package/js/src/web/connections/fetch-now.js +81 -0
- package/js/src/web/connections/github-inbox.js +236 -0
- package/js/src/web/connections/index.js +6 -0
- package/js/src/web/dom.js +17 -0
- package/js/src/web/locales/en-chat-account.js +17 -0
- package/js/src/web/locales/en-github.js +30 -0
- package/js/src/web/locales/en.js +2 -0
- package/js/src/web/locales/hi-chat-account.js +17 -0
- package/js/src/web/locales/hi-github.js +29 -0
- package/js/src/web/locales/hi.js +2 -0
- package/js/src/web/locales/ru-chat-account.js +17 -0
- package/js/src/web/locales/ru-github.js +30 -0
- package/js/src/web/locales/ru.js +2 -0
- package/js/src/web/locales/zh-chat-account.js +16 -0
- package/js/src/web/locales/zh-github.js +28 -0
- package/js/src/web/locales/zh.js +2 -0
- package/js/src/web/message-parts.js +34 -9
- package/package.json +5 -1
|
@@ -27,13 +27,27 @@ const blank = (id) => ({
|
|
|
27
27
|
open: null,
|
|
28
28
|
});
|
|
29
29
|
|
|
30
|
+
// A message time as an ISO string. VK and WhatsApp keep unix seconds (a
|
|
31
|
+
// number), which used to break the contact sort and `/api/status` (R-Z8).
|
|
32
|
+
const seenAt = (value) => {
|
|
33
|
+
if (value === null || value === undefined || value === '') {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const n = Number(value);
|
|
37
|
+
if (Number.isFinite(n) && !/[-:T]/.test(String(value))) {
|
|
38
|
+
return new Date(n < 1e12 ? n * 1000 : n).toISOString();
|
|
39
|
+
}
|
|
40
|
+
return String(value);
|
|
41
|
+
};
|
|
42
|
+
|
|
30
43
|
const addMessage = (entry, m) => {
|
|
31
44
|
entry.networks.add(m.source);
|
|
32
45
|
entry.chats.add(m.chat);
|
|
33
46
|
entry.messageCount += 1;
|
|
34
|
-
const
|
|
47
|
+
const at = seenAt(m.timestamp);
|
|
48
|
+
const latest = !entry.lastSeen || (at ?? '') > entry.lastSeen;
|
|
35
49
|
if (latest) {
|
|
36
|
-
entry.lastSeen =
|
|
50
|
+
entry.lastSeen = at;
|
|
37
51
|
entry.name = m.senderName ?? entry.name;
|
|
38
52
|
} else {
|
|
39
53
|
entry.name ??= m.senderName ?? null;
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub as a full inbox, server side (R-Z11).
|
|
3
|
+
*
|
|
4
|
+
* Which repositories to pull and how often is a saved setting
|
|
5
|
+
* (`settings:github`); where each pull stopped is a cursor link
|
|
6
|
+
* (`cursor:github:<owner/repo>`) written only after the pulled links were
|
|
7
|
+
* stored (R-Z14), so a failed write repeats the same window. A repository
|
|
8
|
+
* that fails does not stop the others; a rate limit stops the run and says
|
|
9
|
+
* when to come back. Notifications are fetched into `notification:github:*`
|
|
10
|
+
* links and marked read only by an explicit call.
|
|
11
|
+
*
|
|
12
|
+
* There is no timer here: `pullJob()` is the job the shared pull runner
|
|
13
|
+
* (../sources/pull-runner.js, R-Z8) registers as `github`, so "Fetch now" and
|
|
14
|
+
* the schedule are the runner's. The schedule is off until the user picks
|
|
15
|
+
* repositories and an interval.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createGithubLive } from '../sources/github.js';
|
|
19
|
+
import { stampSourceLink } from '../sources/index.js';
|
|
20
|
+
import { loadPullState, savePullState } from '../sources/pull-runner.js';
|
|
21
|
+
import { safeMessage } from './redact.js';
|
|
22
|
+
|
|
23
|
+
const SETTINGS_ID = 'settings:github';
|
|
24
|
+
const cursorId = (key) => `cursor:github:${key}`;
|
|
25
|
+
const REPO = /^[A-Za-z0-9_.-]{1,100}\/[A-Za-z0-9_.-]{1,100}$/;
|
|
26
|
+
const MAX_REPOS = 200;
|
|
27
|
+
const MIN_INTERVAL_MINUTES = 5;
|
|
28
|
+
const MAX_INTERVAL_MINUTES = 24 * 60;
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
31
|
+
repos: [],
|
|
32
|
+
intervalMinutes: 0,
|
|
33
|
+
notifications: false,
|
|
34
|
+
discussions: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const secretValue = (link) =>
|
|
38
|
+
link?.token ?? link?.value ?? link?.body ?? link?.text ?? null;
|
|
39
|
+
|
|
40
|
+
export const readGithubToken = async (store, { secretId } = {}) => {
|
|
41
|
+
const ids = [
|
|
42
|
+
secretId,
|
|
43
|
+
'secret:github:access-token',
|
|
44
|
+
'secret:github:token',
|
|
45
|
+
].filter(Boolean);
|
|
46
|
+
for (const id of ids) {
|
|
47
|
+
const token = secretValue(await store.get(id));
|
|
48
|
+
if (token) {
|
|
49
|
+
return token;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const githubLive = async (store, body, ctx) => {
|
|
56
|
+
const token = body.token ?? (await readGithubToken(store, body));
|
|
57
|
+
const factory = ctx?.githubLiveFactory ?? createGithubLive;
|
|
58
|
+
const live = factory({
|
|
59
|
+
token,
|
|
60
|
+
owner: body.owner ?? null,
|
|
61
|
+
repo: body.repo ?? null,
|
|
62
|
+
});
|
|
63
|
+
return { token, live };
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const invalid = (message) =>
|
|
67
|
+
Object.assign(new Error(message), { code: 'bad-request' });
|
|
68
|
+
|
|
69
|
+
/** The settings a request may save, or a `bad-request` error. */
|
|
70
|
+
export const checkSettings = (input = {}) => {
|
|
71
|
+
const repos = input.repos ?? [];
|
|
72
|
+
if (
|
|
73
|
+
!Array.isArray(repos) ||
|
|
74
|
+
repos.length > MAX_REPOS ||
|
|
75
|
+
!repos.every((r) => typeof r === 'string' && REPO.test(r))
|
|
76
|
+
) {
|
|
77
|
+
throw invalid('repos must be a list of owner/repo names');
|
|
78
|
+
}
|
|
79
|
+
const interval = Number(input.intervalMinutes ?? 0);
|
|
80
|
+
if (
|
|
81
|
+
!Number.isInteger(interval) ||
|
|
82
|
+
(interval !== 0 &&
|
|
83
|
+
(interval < MIN_INTERVAL_MINUTES || interval > MAX_INTERVAL_MINUTES))
|
|
84
|
+
) {
|
|
85
|
+
throw invalid(
|
|
86
|
+
`intervalMinutes must be 0 (off) or ${MIN_INTERVAL_MINUTES}..${MAX_INTERVAL_MINUTES}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
repos: [...new Set(repos)],
|
|
91
|
+
intervalMinutes: interval,
|
|
92
|
+
notifications: input.notifications === true,
|
|
93
|
+
discussions: input.discussions === true,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const errorEntry = (error) => ({
|
|
98
|
+
error: safeMessage(error),
|
|
99
|
+
code: error.code ?? 'github-error',
|
|
100
|
+
...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const putAll = async (store, links) => {
|
|
104
|
+
for (const link of links) {
|
|
105
|
+
await store.put(stampSourceLink(link, 'github'));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// One repository: pull, store, then (only then) move its cursor.
|
|
110
|
+
const fetchRepo = async (env, live, token, key, options) => {
|
|
111
|
+
const { store, now } = env;
|
|
112
|
+
const [owner, repo] = key.split('/');
|
|
113
|
+
const cursor = (await store.get(cursorId(key))) ?? {};
|
|
114
|
+
const result = await live.pullMessages({
|
|
115
|
+
owner,
|
|
116
|
+
repo,
|
|
117
|
+
token,
|
|
118
|
+
since: cursor.since ?? null,
|
|
119
|
+
etags: cursor.etags ?? {},
|
|
120
|
+
});
|
|
121
|
+
const found = options.discussions
|
|
122
|
+
? await fetchDiscussionsOf(env, live, { owner, repo, token, cursor })
|
|
123
|
+
: { links: [], since: cursor.discussionsSince ?? null, note: null };
|
|
124
|
+
await putAll(store, [...result.links, ...found.links]);
|
|
125
|
+
// After the store write: a failed write above leaves the cursor as it was.
|
|
126
|
+
await store.put({
|
|
127
|
+
id: cursorId(key),
|
|
128
|
+
source: 'github',
|
|
129
|
+
since: result.since,
|
|
130
|
+
etags: result.etags,
|
|
131
|
+
discussionsSince: found.since,
|
|
132
|
+
fetchedAt: new Date(now()).toISOString(),
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
repo: key,
|
|
136
|
+
imported: result.links.length + found.links.length,
|
|
137
|
+
rawCount: result.rawCount,
|
|
138
|
+
notModified: result.notModified,
|
|
139
|
+
discussions: options.discussions ? found.links.length : null,
|
|
140
|
+
...(found.note ? { discussionsNote: found.note } : {}),
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Discussions need a token with access; without it the repository still
|
|
145
|
+
// pulls and the reason is reported (a rate limit still stops the run).
|
|
146
|
+
const fetchDiscussionsOf = async (
|
|
147
|
+
env,
|
|
148
|
+
live,
|
|
149
|
+
{ owner, repo, token, cursor }
|
|
150
|
+
) => {
|
|
151
|
+
const startedAt = new Date(env.now()).toISOString();
|
|
152
|
+
try {
|
|
153
|
+
const found = await live.fetchDiscussions({
|
|
154
|
+
owner,
|
|
155
|
+
repo,
|
|
156
|
+
token,
|
|
157
|
+
since: cursor.discussionsSince ?? null,
|
|
158
|
+
});
|
|
159
|
+
return { links: found.links, since: startedAt, note: null };
|
|
160
|
+
} catch (error) {
|
|
161
|
+
if (error.code === 'rate-limited') {
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
links: [],
|
|
166
|
+
since: cursor.discussionsSince ?? null,
|
|
167
|
+
note: errorEntry(error),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// The notification inbox, never faster than the interval GitHub asked for.
|
|
173
|
+
const fetchNotes = async ({ store, now }, live, token) => {
|
|
174
|
+
const id = cursorId('notifications');
|
|
175
|
+
const cursor = (await store.get(id)) ?? {};
|
|
176
|
+
const next = Number(cursor.nextPollAt ?? 0);
|
|
177
|
+
if (next > now()) {
|
|
178
|
+
return { skipped: 'poll-interval', nextPollAt: next };
|
|
179
|
+
}
|
|
180
|
+
const startedAt = new Date(now()).toISOString();
|
|
181
|
+
const found = await live.fetchNotifications({
|
|
182
|
+
token,
|
|
183
|
+
since: cursor.since ?? null,
|
|
184
|
+
lastModified: cursor.lastModified ?? null,
|
|
185
|
+
});
|
|
186
|
+
await putAll(store, found.links);
|
|
187
|
+
const pollInterval = found.pollInterval ?? cursor.pollInterval ?? 60;
|
|
188
|
+
await store.put({
|
|
189
|
+
id,
|
|
190
|
+
source: 'github',
|
|
191
|
+
since: found.notModified ? (cursor.since ?? startedAt) : startedAt,
|
|
192
|
+
lastModified: found.lastModified,
|
|
193
|
+
pollInterval,
|
|
194
|
+
nextPollAt: now() + pollInterval * 1000,
|
|
195
|
+
});
|
|
196
|
+
return { imported: found.links.length, notModified: found.notModified };
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
const storedNotifications = async (store) => {
|
|
200
|
+
const all = await store.query(
|
|
201
|
+
(l) => typeof l?.id === 'string' && l.id.startsWith('notification:github:')
|
|
202
|
+
);
|
|
203
|
+
return all
|
|
204
|
+
.filter((l) => !l.deleted)
|
|
205
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
// A repository that fails does not stop the others; a rate limit does.
|
|
209
|
+
const runAll = async (env, saved, wanted, { token, live }, log) => {
|
|
210
|
+
const out = { repos: [], notifications: null, rateLimited: null };
|
|
211
|
+
const limited = (error) => {
|
|
212
|
+
if (error.code === 'rate-limited') {
|
|
213
|
+
out.rateLimited = { retryAfter: error.retryAfter };
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
for (const key of wanted) {
|
|
217
|
+
if (out.rateLimited) {
|
|
218
|
+
out.repos.push({ repo: key, skipped: 'rate-limited' });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
try {
|
|
222
|
+
out.repos.push(await fetchRepo(env, live, token, key, saved));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
out.repos.push({ repo: key, ...errorEntry(error) });
|
|
225
|
+
limited(error);
|
|
226
|
+
log?.({ level: 'warn', event: 'github_fetch_failed', repo: key });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (saved.notifications && !out.rateLimited) {
|
|
230
|
+
out.notifications = await fetchNotes(env, live, token).catch((error) => {
|
|
231
|
+
limited(error);
|
|
232
|
+
return errorEntry(error);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
out.imported = out.repos.reduce((n, r) => n + (r.imported ?? 0), 0);
|
|
236
|
+
return out;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* @param {object} options
|
|
241
|
+
* @param {object} options.store
|
|
242
|
+
* @param {object} [options.ctx] server context (`githubLiveFactory`)
|
|
243
|
+
* @param {() => number} [options.now]
|
|
244
|
+
* @param {Function} [options.log]
|
|
245
|
+
*/
|
|
246
|
+
export const createGithubService = ({
|
|
247
|
+
store,
|
|
248
|
+
ctx = {},
|
|
249
|
+
now = () => Date.now(),
|
|
250
|
+
log = null,
|
|
251
|
+
}) => {
|
|
252
|
+
const env = { store, now };
|
|
253
|
+
let running = null;
|
|
254
|
+
|
|
255
|
+
const settings = async () => {
|
|
256
|
+
const { repos, intervalMinutes, notifications, discussions } = {
|
|
257
|
+
...DEFAULT_SETTINGS,
|
|
258
|
+
...((await store.get(SETTINGS_ID)) ?? {}),
|
|
259
|
+
};
|
|
260
|
+
return { repos, intervalMinutes, notifications, discussions };
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const saveSettings = async (input) => {
|
|
264
|
+
const next = checkSettings(input);
|
|
265
|
+
await store.put({ id: SETTINGS_ID, source: 'github', ...next });
|
|
266
|
+
return next;
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
const withLive = async () => {
|
|
270
|
+
const { token, live } = await githubLive(store, {}, ctx);
|
|
271
|
+
if (!token) {
|
|
272
|
+
throw Object.assign(new Error('save a GitHub access token first'), {
|
|
273
|
+
code: 'no-token',
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return { token, live };
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const listRepos = async () => {
|
|
280
|
+
const { token, live } = await withLive();
|
|
281
|
+
const repos = await live.listRepos({ token });
|
|
282
|
+
const chosen = new Set((await settings()).repos);
|
|
283
|
+
return repos.map((r) => ({
|
|
284
|
+
fullName: r.full_name,
|
|
285
|
+
private: Boolean(r.private),
|
|
286
|
+
description: r.description ?? '',
|
|
287
|
+
updatedAt: r.pushed_at ?? r.updated_at ?? null,
|
|
288
|
+
selected: chosen.has(r.full_name),
|
|
289
|
+
}));
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
const run = async (options = {}) => {
|
|
293
|
+
const saved = await settings();
|
|
294
|
+
const access = await withLive();
|
|
295
|
+
const wanted = options.repos?.length ? options.repos : saved.repos;
|
|
296
|
+
return runAll(env, saved, wanted, access, log);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// One run at a time: a second request waits for and shares the first.
|
|
300
|
+
const fetchNow = (options) => {
|
|
301
|
+
running ??= run(options).finally(() => {
|
|
302
|
+
running = null;
|
|
303
|
+
});
|
|
304
|
+
return running;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
const notifications = () => storedNotifications(store);
|
|
308
|
+
|
|
309
|
+
// Marking read changes GitHub: only on an explicit call (never by fetching).
|
|
310
|
+
const markRead = async ({ threadId = null, all = false } = {}) => {
|
|
311
|
+
const { token, live } = await withLive();
|
|
312
|
+
if (all) {
|
|
313
|
+
await live.markAllRead({ token });
|
|
314
|
+
for (const link of await notifications()) {
|
|
315
|
+
await store.put({ ...link, unread: false });
|
|
316
|
+
}
|
|
317
|
+
return { ok: true, all: true };
|
|
318
|
+
}
|
|
319
|
+
await live.markThreadRead(threadId, { token });
|
|
320
|
+
const link = await store.get(`notification:github:${threadId}`);
|
|
321
|
+
if (link) {
|
|
322
|
+
await store.put({ ...link, unread: false });
|
|
323
|
+
}
|
|
324
|
+
return { ok: true, threadId: String(threadId) };
|
|
325
|
+
};
|
|
326
|
+
|
|
327
|
+
// The runner's job. A rate limit is remembered (`notBefore` in the pull
|
|
328
|
+
// state), so neither the schedule nor a click asks GitHub again before it
|
|
329
|
+
// allows, and the wait is reported instead of looped on.
|
|
330
|
+
const waitLeft = async () =>
|
|
331
|
+
Math.ceil(
|
|
332
|
+
(Number((await loadPullState(store, 'github')).notBefore ?? 0) - now()) /
|
|
333
|
+
1000
|
|
334
|
+
);
|
|
335
|
+
const limitError = (seconds) =>
|
|
336
|
+
Object.assign(new Error('GitHub rate limit reached; try again later'), {
|
|
337
|
+
code: 'rate-limited',
|
|
338
|
+
retryAfter: Math.max(1, seconds),
|
|
339
|
+
});
|
|
340
|
+
const pullJob = () => ({
|
|
341
|
+
ready: async () => Boolean(await readGithubToken(store)),
|
|
342
|
+
intervalMs: 60_000,
|
|
343
|
+
firstDelayMs: 30_000,
|
|
344
|
+
due: async () => {
|
|
345
|
+
const saved = await settings();
|
|
346
|
+
if (!saved.intervalMinutes || saved.repos.length === 0) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
const state = await loadPullState(store, 'github');
|
|
350
|
+
const last = Date.parse(state.lastOkAt ?? '') || 0;
|
|
351
|
+
return (
|
|
352
|
+
now() >=
|
|
353
|
+
Math.max(last + saved.intervalMinutes * 60_000, state.notBefore ?? 0)
|
|
354
|
+
);
|
|
355
|
+
},
|
|
356
|
+
run: async () => {
|
|
357
|
+
const left = await waitLeft();
|
|
358
|
+
if (left > 0) {
|
|
359
|
+
throw limitError(left);
|
|
360
|
+
}
|
|
361
|
+
const out = await fetchNow({});
|
|
362
|
+
if (out.rateLimited) {
|
|
363
|
+
const seconds = out.rateLimited.retryAfter ?? 60;
|
|
364
|
+
await savePullState(store, 'github', {
|
|
365
|
+
notBefore: now() + seconds * 1000,
|
|
366
|
+
});
|
|
367
|
+
throw limitError(seconds);
|
|
368
|
+
}
|
|
369
|
+
await savePullState(store, 'github', { notBefore: 0 });
|
|
370
|
+
return { imported: out.imported };
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
settings,
|
|
376
|
+
saveSettings,
|
|
377
|
+
listRepos,
|
|
378
|
+
fetchNow,
|
|
379
|
+
notifications,
|
|
380
|
+
markRead,
|
|
381
|
+
pullJob,
|
|
382
|
+
};
|
|
383
|
+
};
|
package/js/src/server/index.js
CHANGED
|
@@ -104,6 +104,7 @@ import { createGitHubCheck } from '../sources/github-check.js';
|
|
|
104
104
|
import { handleMetrics } from './metrics.js';
|
|
105
105
|
import { jsonLog } from './log.js';
|
|
106
106
|
import { listenLocal } from './local-tls.js';
|
|
107
|
+
import { handlePullRoutes, pullContext } from './routes-pull.js';
|
|
107
108
|
import { createPeer, attachSyncWebSocket } from '../sync/index.js';
|
|
108
109
|
import { attachSignaling } from '../sync/webrtc-signaling.js';
|
|
109
110
|
import { registerDefaultHandlers } from './handlers-bootstrap.js';
|
|
@@ -148,7 +149,9 @@ export const CSP =
|
|
|
148
149
|
"default-src 'self'; " +
|
|
149
150
|
"script-src 'self' 'wasm-unsafe-eval'; " +
|
|
150
151
|
"style-src 'self'; " +
|
|
151
|
-
"img-src 'self' data: blob: https://vk.com
|
|
152
|
+
"img-src 'self' data: blob: https://vk.com " +
|
|
153
|
+
// VK's picture CDNs, for photos and stickers in VK chats (R-Z8).
|
|
154
|
+
'https://*.userapi.com https://*.vk-cdn.net https://*.vkuserphoto.ru; ' +
|
|
152
155
|
'frame-src https:; ' +
|
|
153
156
|
"connect-src 'self' https: ws: wss: http://127.0.0.1:* http://localhost:*; " +
|
|
154
157
|
"worker-src 'self'; " +
|
|
@@ -576,6 +579,7 @@ const routeOn = async (store, req, res, ctx, url, p) => {
|
|
|
576
579
|
() => ctx.clientLog?.(req, res, p, ctx) ?? false,
|
|
577
580
|
() => handleAutoImportRoutes(store, req, res, p, ctx),
|
|
578
581
|
() => handleWhatsAppRoutes(store, req, res, p, ctx),
|
|
582
|
+
() => handlePullRoutes(store, req, res, p, ctx),
|
|
579
583
|
() => handleTelegramRoutes(req, res, p, ctx),
|
|
580
584
|
() => handleMessageRoutes(store, req, res, p, url, ctx),
|
|
581
585
|
() => handleEmailGets(store, req, res, p, url, ctx),
|
|
@@ -787,15 +791,14 @@ export const startServer = async ({
|
|
|
787
791
|
telegramExtraBuild,
|
|
788
792
|
// Tests and demos pass a fake, so GitHub is never asked (R-Y53).
|
|
789
793
|
githubCheck,
|
|
790
|
-
// The hosted app and whether this
|
|
791
|
-
// (./hosted-ui.js, R-Y58 and R-Y59).
|
|
794
|
+
// The hosted app, and whether this serves its own copy (./hosted-ui.js).
|
|
792
795
|
uiUrl,
|
|
793
796
|
localUi,
|
|
794
|
-
// HTTPS on the same port for Safari (./local-tls.js, R-Y64);
|
|
795
|
-
// unless asked for, `serve` turns it on.
|
|
797
|
+
// HTTPS on the same port for Safari (./local-tls.js, R-Y64); `serve` sets it.
|
|
796
798
|
tls,
|
|
797
799
|
// Tests: a fake `(dir) => bytes|null` for the free disk space (R-Z14).
|
|
798
800
|
diskFree,
|
|
801
|
+
pulls,
|
|
799
802
|
} = {}) => {
|
|
800
803
|
const { store, resolvedArchiveDir, flush, storeHealth } = await initStore({
|
|
801
804
|
providedStore,
|
|
@@ -853,13 +856,13 @@ export const startServer = async ({
|
|
|
853
856
|
later
|
|
854
857
|
),
|
|
855
858
|
chatIndex,
|
|
859
|
+
...pullContext(store, log, pulls, later),
|
|
856
860
|
};
|
|
857
861
|
const server = createHttpServer(store, ctx, log);
|
|
858
862
|
const sockets = trackSockets(server);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
}
|
|
863
|
+
const bus = enableHandlers
|
|
864
|
+
? registerDefaultHandlers(store, { browser: () => ctx.send })
|
|
865
|
+
: null;
|
|
863
866
|
const { wsHandle, signaling } = attachSockets(server, ctx, store, {
|
|
864
867
|
enableSync,
|
|
865
868
|
node,
|
|
@@ -903,6 +906,7 @@ export const startServer = async ({
|
|
|
903
906
|
ctx.closeAutomations().catch(() => {});
|
|
904
907
|
ctx.autoImport?.close();
|
|
905
908
|
ctx.whatsappSignIn?.stop().catch(() => {});
|
|
909
|
+
ctx.stopPulls?.();
|
|
906
910
|
ctx.chatIndex.close();
|
|
907
911
|
stopAll(telegramAccount, ctx.telegramAccounts, ctx.telegramExtras);
|
|
908
912
|
wsHandle?.close();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GitHub inbox routes (R-Z11), all under /api/github/:
|
|
3
|
+
*
|
|
4
|
+
* GET repos the user's repositories, `selected` marked
|
|
5
|
+
* GET settings { repos, intervalMinutes, notifications,
|
|
6
|
+
* discussions }
|
|
7
|
+
* POST settings the same, saved
|
|
8
|
+
* GET notifications the stored notification inbox
|
|
9
|
+
* POST notifications/read { threadId } | { all: true } -- the only
|
|
10
|
+
* thing that marks anything read on GitHub
|
|
11
|
+
*
|
|
12
|
+
* "Fetch now" and the schedule are the shared pull runner's: POST /api/pull
|
|
13
|
+
* {source: "github"} (./routes-pull.js). Pull, clone and post-comment stay in
|
|
14
|
+
* ./routes-mutating.js. Answers are
|
|
15
|
+
* `{ ok, ... }` or the uniform `{ ok: false, error, code }`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { fail, json, readBody } from './util.js';
|
|
19
|
+
import { isTrustedMutation } from './routes-auto-import.js';
|
|
20
|
+
import { safeMessage } from './redact.js';
|
|
21
|
+
|
|
22
|
+
const STATUS = {
|
|
23
|
+
'bad-request': 400,
|
|
24
|
+
'no-token': 400,
|
|
25
|
+
'rate-limited': 429,
|
|
26
|
+
'discussions-unavailable': 403,
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const failure = (res, error) => {
|
|
30
|
+
const code = error.code ?? 'github-error';
|
|
31
|
+
const status = STATUS[code] ?? (error.status === 401 ? 401 : 502);
|
|
32
|
+
return (
|
|
33
|
+
json(res, status, {
|
|
34
|
+
ok: false,
|
|
35
|
+
error: safeMessage(error),
|
|
36
|
+
code,
|
|
37
|
+
...(error.retryAfter ? { retryAfter: error.retryAfter } : {}),
|
|
38
|
+
}) ?? true
|
|
39
|
+
);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const guarded = async (res, work) => {
|
|
43
|
+
try {
|
|
44
|
+
return (await work()) ?? true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return failure(res, error);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const mutation = async (req, res, ctx, work) => {
|
|
51
|
+
if (!isTrustedMutation(req, ctx.allowOrigins)) {
|
|
52
|
+
return fail(
|
|
53
|
+
res,
|
|
54
|
+
403,
|
|
55
|
+
'forbidden',
|
|
56
|
+
'GitHub settings must be changed from the meta-sovereign app'
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
return guarded(res, async () => work(await readBody(req)));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const READ = {
|
|
63
|
+
repos: async (service) => ({ repos: await service.listRepos() }),
|
|
64
|
+
settings: async (service) => ({ settings: await service.settings() }),
|
|
65
|
+
notifications: async (service) => ({
|
|
66
|
+
notifications: await service.notifications(),
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const WRITE = {
|
|
71
|
+
settings: async (service, body) => ({
|
|
72
|
+
settings: await service.saveSettings(body),
|
|
73
|
+
}),
|
|
74
|
+
'notifications/read': (service, body) =>
|
|
75
|
+
service.markRead({
|
|
76
|
+
threadId: body.threadId ?? null,
|
|
77
|
+
all: body.all === true,
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const handleGithubRoutes = async (req, res, p, ctx) => {
|
|
82
|
+
const prefix = '/api/github/';
|
|
83
|
+
if (!p.startsWith(prefix) || !ctx.github) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const name = p.slice(prefix.length);
|
|
87
|
+
if (req.method === 'GET' && Object.hasOwn(READ, name)) {
|
|
88
|
+
return guarded(res, async () =>
|
|
89
|
+
json(res, 200, { ok: true, ...(await READ[name](ctx.github)) })
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (req.method === 'POST' && Object.hasOwn(WRITE, name)) {
|
|
93
|
+
return mutation(req, res, ctx, async (body) =>
|
|
94
|
+
json(res, 200, { ok: true, ...(await WRITE[name](ctx.github, body)) })
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
};
|
|
@@ -170,6 +170,13 @@ const ACTIONS = {
|
|
|
170
170
|
},
|
|
171
171
|
};
|
|
172
172
|
|
|
173
|
+
// An item pulled before its network id was kept is refused (R-Z11).
|
|
174
|
+
const FAILURE_STATUS = {
|
|
175
|
+
'flood-wait': 429,
|
|
176
|
+
'unsupported-action': 501,
|
|
177
|
+
'no-external-id': 409,
|
|
178
|
+
};
|
|
179
|
+
|
|
173
180
|
const handleAction = async (store, req, res, ctx, name) => {
|
|
174
181
|
const action = ACTIONS[name];
|
|
175
182
|
const body = await readBody(req);
|
|
@@ -197,12 +204,7 @@ const handleAction = async (store, req, res, ctx, name) => {
|
|
|
197
204
|
source: target.source,
|
|
198
205
|
code: error.code ?? null,
|
|
199
206
|
});
|
|
200
|
-
const status =
|
|
201
|
-
error.code === 'flood-wait'
|
|
202
|
-
? 429
|
|
203
|
-
: error.code === 'unsupported-action'
|
|
204
|
-
? 501
|
|
205
|
-
: 502;
|
|
207
|
+
const status = FAILURE_STATUS[error.code] ?? 502;
|
|
206
208
|
return (
|
|
207
209
|
json(res, status, {
|
|
208
210
|
error: error.message,
|
|
@@ -17,7 +17,8 @@ import {
|
|
|
17
17
|
stampSourceLink,
|
|
18
18
|
} from '../sources/index.js';
|
|
19
19
|
import { emailFailure, handleEmailPull, sendEmail } from './routes-email.js';
|
|
20
|
-
import {
|
|
20
|
+
import { githubLive } from './github-service.js';
|
|
21
|
+
import { handleGithubRoutes } from './routes-github.js';
|
|
21
22
|
import { createUpworkLive } from '../sources/upwork.js';
|
|
22
23
|
import { createPeoplePerHourLive } from '../sources/peopleperhour.js';
|
|
23
24
|
import {
|
|
@@ -523,33 +524,6 @@ const handleEmail = async (store, req, res, p, ctx) => {
|
|
|
523
524
|
return false;
|
|
524
525
|
};
|
|
525
526
|
|
|
526
|
-
const readGithubToken = async (store, { secretId } = {}) => {
|
|
527
|
-
const ids = [
|
|
528
|
-
secretId,
|
|
529
|
-
'secret:github:access-token',
|
|
530
|
-
'secret:github:token',
|
|
531
|
-
].filter(Boolean);
|
|
532
|
-
for (const id of ids) {
|
|
533
|
-
const link = await store.get(id);
|
|
534
|
-
const token = link?.token ?? link?.value ?? link?.body ?? link?.text;
|
|
535
|
-
if (token) {
|
|
536
|
-
return token;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
return null;
|
|
540
|
-
};
|
|
541
|
-
|
|
542
|
-
const githubLive = async (store, body, ctx) => {
|
|
543
|
-
const token = body.token ?? (await readGithubToken(store, body));
|
|
544
|
-
const factory = ctx?.githubLiveFactory ?? createGithubLive;
|
|
545
|
-
const live = factory({
|
|
546
|
-
token,
|
|
547
|
-
owner: body.owner ?? null,
|
|
548
|
-
repo: body.repo ?? null,
|
|
549
|
-
});
|
|
550
|
-
return { token, live };
|
|
551
|
-
};
|
|
552
|
-
|
|
553
527
|
const pullGithub = async (store, body, ctx) => {
|
|
554
528
|
const { token, live } = await githubLive(store, body, ctx);
|
|
555
529
|
const result = await live.pullMessages({ ...body, token });
|
|
@@ -584,10 +558,18 @@ const cloneGithub = async (store, body, ctx) => {
|
|
|
584
558
|
|
|
585
559
|
const postGithubComment = async (store, body, ctx) => {
|
|
586
560
|
const { token, live } = await githubLive(store, body, ctx);
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
561
|
+
let content = body.content ?? body.message ?? body;
|
|
562
|
+
// A reply names the message it answers; its saved GitHub id picks the
|
|
563
|
+
// endpoint (a review comment has its own reply route) (R-Z11).
|
|
564
|
+
if (typeof body.replyTo === 'string' && body.replyTo.startsWith('msg:')) {
|
|
565
|
+
const replyToLink = await store.get(body.replyTo);
|
|
566
|
+
content = {
|
|
567
|
+
...(typeof content === 'string' ? { text: content } : content),
|
|
568
|
+
chat: body.chat ?? replyToLink?.chat,
|
|
569
|
+
replyToLink,
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const result = await live.post(content, { ...body, token });
|
|
591
573
|
return { source: 'github', result };
|
|
592
574
|
};
|
|
593
575
|
|
|
@@ -805,6 +787,7 @@ export const handleMutatingRoutes = async (store, req, res, p, url, ctx) =>
|
|
|
805
787
|
(await handleOutreach(store, req, res, p, ctx)) ||
|
|
806
788
|
(await handleEmail(store, req, res, p, ctx)) ||
|
|
807
789
|
(await handleGithub(store, req, res, p, ctx)) ||
|
|
790
|
+
(await handleGithubRoutes(req, res, p, ctx)) ||
|
|
808
791
|
(await handleUpwork(store, req, res, p, ctx)) ||
|
|
809
792
|
(await handlePeoplePerHour(store, req, res, p, ctx)) ||
|
|
810
793
|
(await handleHardening(store, req, res, p, ctx));
|