querysub 0.327.0 → 0.329.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/bin/error-email.js +8 -0
- package/bin/error-im.js +8 -0
- package/package.json +4 -3
- package/src/-a-archives/archivesBackBlaze.ts +20 -0
- package/src/-a-archives/archivesCborT.ts +52 -0
- package/src/-a-archives/archivesDisk.ts +5 -5
- package/src/-a-archives/archivesJSONT.ts +19 -5
- package/src/-a-archives/archivesLimitedCache.ts +118 -7
- package/src/-a-archives/archivesPrivateFileSystem.ts +3 -0
- package/src/-g-core-values/NodeCapabilities.ts +26 -11
- package/src/0-path-value-core/auditLogs.ts +4 -2
- package/src/2-proxy/PathValueProxyWatcher.ts +7 -0
- package/src/3-path-functions/PathFunctionRunner.ts +2 -2
- package/src/4-querysub/Querysub.ts +1 -1
- package/src/5-diagnostics/GenericFormat.tsx +2 -2
- package/src/config.ts +15 -3
- package/src/deployManager/machineApplyMainCode.ts +10 -8
- package/src/deployManager/machineSchema.ts +4 -3
- package/src/deployManager/setupMachineMain.ts +3 -2
- package/src/diagnostics/logs/FastArchiveAppendable.ts +86 -53
- package/src/diagnostics/logs/FastArchiveController.ts +11 -2
- package/src/diagnostics/logs/FastArchiveViewer.tsx +205 -48
- package/src/diagnostics/logs/LogViewer2.tsx +78 -34
- package/src/diagnostics/logs/TimeRangeSelector.tsx +8 -0
- package/src/diagnostics/logs/diskLogGlobalContext.ts +5 -4
- package/src/diagnostics/logs/diskLogger.ts +70 -23
- package/src/diagnostics/logs/errorNotifications/ErrorDigestPage.tsx +409 -0
- package/src/diagnostics/logs/errorNotifications/ErrorNotificationController.ts +94 -67
- package/src/diagnostics/logs/errorNotifications/ErrorSuppressionUI.tsx +37 -3
- package/src/diagnostics/logs/errorNotifications/ErrorWarning.tsx +50 -16
- package/src/diagnostics/logs/errorNotifications/errorDigestEmail.tsx +174 -0
- package/src/diagnostics/logs/errorNotifications/errorDigests.tsx +291 -0
- package/src/diagnostics/logs/errorNotifications/errorLoopEntry.tsx +7 -0
- package/src/diagnostics/logs/errorNotifications/errorWatchEntry.tsx +185 -68
- package/src/diagnostics/logs/lifeCycleAnalysis/spec.md +10 -19
- package/src/diagnostics/managementPages.tsx +33 -15
- package/src/email_ims_notifications/discord.tsx +203 -0
- package/src/{email → email_ims_notifications}/postmark.tsx +3 -3
- package/src/fs.ts +9 -0
- package/src/functional/SocketChannel.ts +9 -0
- package/src/functional/throttleRender.ts +134 -0
- package/src/library-components/ATag.tsx +2 -2
- package/src/library-components/SyncedController.ts +3 -3
- package/src/misc.ts +18 -0
- package/src/misc2.ts +106 -0
- package/src/user-implementation/SecurityPage.tsx +11 -5
- package/src/user-implementation/userData.ts +57 -23
- package/testEntry2.ts +14 -5
- package/src/user-implementation/setEmailKey.ts +0 -25
- /package/src/{email → email_ims_notifications}/sendgrid.tsx +0 -0
|
@@ -1,79 +1,196 @@
|
|
|
1
1
|
import { batchFunction, runInfinitePollCallAtStart } from "socket-function/src/batching";
|
|
2
2
|
import { getControllerNodeId } from "../../../-g-core-values/NodeCapabilities";
|
|
3
|
-
import { RecentErrorsController, recentErrorsChannel, watchRecentErrors } from "./ErrorNotificationController";
|
|
4
|
-
import { timeInSecond } from "socket-function/src/misc";
|
|
5
|
-
import { formatDateTime } from "socket-function/src/formatting/format";
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
3
|
+
import { RecentErrors, RecentErrorsController, errorWatcherBase, recentErrorsChannel, suppressionList, watchRecentErrors } from "./ErrorNotificationController";
|
|
4
|
+
import { sort, timeInDay, timeInHour, timeInMinute, timeInSecond } from "socket-function/src/misc";
|
|
5
|
+
import { formatDate, formatDateTime } from "socket-function/src/formatting/format";
|
|
6
|
+
import { getDomain } from "../../../config";
|
|
7
|
+
import { Querysub, QuerysubController } from "../../../4-querysub/QuerysubController";
|
|
8
|
+
import { formatPercent } from "socket-function/src/formatting/format";
|
|
9
|
+
import { LogDatum, getLogFile } from "../diskLogger";
|
|
10
|
+
import { sendDiscordMessage } from "../../../email_ims_notifications/discord";
|
|
11
|
+
import { user_data } from "../../../user-implementation/userData";
|
|
12
|
+
import { createLink } from "../../../library-components/ATag";
|
|
13
|
+
import { getErrorLogsLink } from "./ErrorWarning";
|
|
14
|
+
|
|
15
|
+
const MAX_IMS_PER_DAY = 3;
|
|
16
|
+
const MAX_IMS_PER_HOURS = 1;
|
|
17
|
+
const MAX_IMS_PER_FILE_PER_DAY = 1;
|
|
18
|
+
const MAX_PER_FILE_PER_IM = 2;
|
|
19
|
+
const MAX_PER_IM = 10;
|
|
20
|
+
|
|
21
|
+
// Wait a bit, because it's likely if there's one error, there are more errors.
|
|
22
|
+
const BATCH_TIME = timeInSecond * 30;
|
|
23
|
+
|
|
24
|
+
// 11) Deploy services to service
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
// NOTE: Yes, this is stored in memory, so if the server reboots or if this script keeps crashing, we might send a lot of instant messages. However, one, Discord will probably late rate limit us, and two, this means something is really wrong, especially if it happens a lot, and we really should fix it right away.
|
|
28
|
+
// NOTE: If we decide not to send IMs, we don't queue them up, because that's just extremely annoying. The point of the limit isn't to send the maximum we can that stays just under the limit! The point of the limit is if a lot happens at once, ignore most of it.
|
|
29
|
+
type IMInfo = {
|
|
30
|
+
time: number;
|
|
31
|
+
perFile: {
|
|
32
|
+
[file: string]: LogDatum[];
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let imHistory: IMInfo[] = [];
|
|
37
|
+
function filterIMInfo(info: IMInfo): {
|
|
38
|
+
info: IMInfo;
|
|
39
|
+
countFiltered: number;
|
|
40
|
+
} {
|
|
41
|
+
let countFiltered = 0;
|
|
42
|
+
// Don't prefer the most warnings, prefer The oldest warnings, which are generally the ones that are first in the object.
|
|
43
|
+
// filter based on files per im, and max per im
|
|
44
|
+
for (let [key, value] of Object.entries(info.perFile)) {
|
|
45
|
+
if (value.length > MAX_PER_FILE_PER_IM) {
|
|
46
|
+
countFiltered += value.length - MAX_PER_FILE_PER_IM;
|
|
47
|
+
value = value.slice(0, MAX_PER_FILE_PER_IM);
|
|
48
|
+
}
|
|
49
|
+
info.perFile[key] = value;
|
|
50
|
+
}
|
|
51
|
+
let entries = Object.entries(info.perFile);
|
|
52
|
+
if (entries.length > MAX_PER_IM) {
|
|
53
|
+
let removed = entries.slice(MAX_PER_IM);
|
|
54
|
+
for (let [key, value] of removed) {
|
|
55
|
+
countFiltered += value.length;
|
|
56
|
+
}
|
|
57
|
+
entries = entries.slice(0, MAX_PER_IM);
|
|
58
|
+
}
|
|
59
|
+
info.perFile = Object.fromEntries(entries);
|
|
60
|
+
// Also ignore files if they've been mentioned too many times today.
|
|
61
|
+
let dayThreshold = Date.now() - timeInDay;
|
|
62
|
+
let historyInDay = imHistory.filter(x => x.time > dayThreshold);
|
|
63
|
+
let countByFile = new Map<string, number>();
|
|
64
|
+
for (let obj of historyInDay) {
|
|
65
|
+
for (let [key, value] of Object.entries(obj.perFile)) {
|
|
66
|
+
countByFile.set(key, (countByFile.get(key) || 0) + value.length);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
for (let key of Object.keys(info.perFile)) {
|
|
70
|
+
let count = countByFile.get(key) || 0;
|
|
71
|
+
if (count >= MAX_IMS_PER_FILE_PER_DAY) {
|
|
72
|
+
countFiltered += count;
|
|
73
|
+
delete info.perFile[key];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
info,
|
|
78
|
+
countFiltered,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
function canSendNow(info: IMInfo) {
|
|
82
|
+
let dayThreshold = Date.now() - timeInDay;
|
|
83
|
+
let historyInDay = imHistory.filter(x => x.time > dayThreshold);
|
|
84
|
+
let hourThreshold = Date.now() - timeInHour;
|
|
85
|
+
let historyInHour = historyInDay.filter(x => x.time > hourThreshold);
|
|
86
|
+
|
|
87
|
+
if (historyInDay.length >= MAX_IMS_PER_DAY) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (historyInHour.length >= MAX_IMS_PER_HOURS) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
37
95
|
|
|
96
|
+
const sendIMs = batchFunction(({ delay: BATCH_TIME }), async (logsAll: LogDatum[][]) => {
|
|
97
|
+
let logs = logsAll.flat();
|
|
98
|
+
let infoBase: IMInfo = {
|
|
99
|
+
time: Date.now(),
|
|
100
|
+
perFile: {},
|
|
101
|
+
};
|
|
102
|
+
for (let log of logs) {
|
|
103
|
+
let file = getLogFile(log);
|
|
104
|
+
let array = infoBase.perFile[file];
|
|
105
|
+
if (!array) {
|
|
106
|
+
array = [];
|
|
107
|
+
infoBase.perFile[file] = array;
|
|
108
|
+
}
|
|
109
|
+
array.push(log);
|
|
110
|
+
}
|
|
111
|
+
let { info, countFiltered } = filterIMInfo(infoBase);
|
|
112
|
+
if (canSendNow(info)) {
|
|
113
|
+
imHistory.push(info);
|
|
114
|
+
let webhookURL = await Querysub.commitAsync(() => {
|
|
115
|
+
return user_data().secure.notifyDiscordWebhookURL;
|
|
116
|
+
});
|
|
117
|
+
if (!webhookURL) {
|
|
118
|
+
console.error(`No Discord webhook URL set, cannot send warning instant messages`);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
let url = createLink(getErrorLogsLink());
|
|
122
|
+
let message = Object.values(info.perFile).flat().map(
|
|
123
|
+
x => `[${formatDateTime(x.time)}](${url}) | ${x.param0} (${x.__NAME__})`
|
|
124
|
+
).join("\n");
|
|
125
|
+
if (countFiltered > 0) {
|
|
126
|
+
message += `\n+${countFiltered} more errors`;
|
|
127
|
+
}
|
|
128
|
+
void sendDiscordMessage({
|
|
129
|
+
webhookURL,
|
|
130
|
+
message,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
});
|
|
38
134
|
|
|
39
|
-
// 7) Write the digest script, which is very different, but will run in the same entry.
|
|
40
|
-
// - Separate warnings and errors and also bucket by time bucket
|
|
41
|
-
// - suppressed errors by time bucket (but no type, as we definitely don't want to parse all suppressed errors...)
|
|
42
|
-
// - Time the entire thing, and put that, and the profile, in the digest too! That will give us a good gauge on if the errors/suppressions are getting slow (due to a lot of errors, or a lot of suppression checks!)
|
|
43
|
-
// 8) Write a page that shows the results of the digest in tabs, writing the digest probably just to backblaze
|
|
44
|
-
// - For now, just have two tabs, one for errors and one for warnings.
|
|
45
|
-
// - If we're going to do a full scan, we might as well show time series data as well. It's trivial.
|
|
46
|
-
// - Also track the number of suppressed errors as well. We won't have details on these such as a breakdown, but we can at least show the count (and the count by time)
|
|
47
|
-
// 9) send an email every time period, and also send an IM that has smaller information
|
|
48
|
-
// - Both will link to the actual web page that has the digest, deep linking to the specific tabs.
|
|
49
|
-
// - Show the chart in the email as well, but just format it like ASCII Because image dependencies are annoying and I don't want to implement them right now as it might take a few days to get working.
|
|
50
135
|
|
|
51
136
|
async function runIMNotifies() {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
137
|
+
await Querysub.hostService("error-notifications");
|
|
138
|
+
|
|
139
|
+
// NOTE: This should be fine, as realistically how many errors are we going to see in the last two weeks. At 10 million, we're still probably only going to allocate 160 megs of memory, assuming we allocate 16 bytes per number. If we have more than 16 million for a single thread, it'll fail because the max set size... However, they will likely be somewhat distributed between threads.
|
|
140
|
+
let errorByThreadIdByDay = new Map<number, Map<string, Set<number>>>();
|
|
141
|
+
function getDay(time: number) {
|
|
142
|
+
return Math.floor(time / timeInDay) * timeInDay;
|
|
143
|
+
}
|
|
144
|
+
function isDuplicate(obj: LogDatum): boolean {
|
|
145
|
+
// Checks if it's a duplicate, and if it's not, adds it.
|
|
146
|
+
let day = getDay(obj.time);
|
|
147
|
+
let threadId = obj.__threadId || "";
|
|
148
|
+
let dayMap = errorByThreadIdByDay.get(day);
|
|
149
|
+
if (!dayMap) {
|
|
150
|
+
dayMap = new Map<string, Set<number>>();
|
|
151
|
+
errorByThreadIdByDay.set(day, dayMap);
|
|
152
|
+
}
|
|
153
|
+
let threadIdMap = dayMap.get(threadId);
|
|
154
|
+
if (!threadIdMap) {
|
|
155
|
+
threadIdMap = new Set<number>();
|
|
156
|
+
dayMap.set(threadId, threadIdMap);
|
|
157
|
+
}
|
|
158
|
+
if (threadIdMap.has(obj.time)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
threadIdMap.add(obj.time);
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
function clearOldDays() {
|
|
165
|
+
// Clear all the days that are more than 14 days older than our current day.
|
|
166
|
+
let now = Date.now();
|
|
167
|
+
let currentDay = getDay(now);
|
|
168
|
+
let cutOffDay = currentDay - 14;
|
|
169
|
+
for (let day of errorByThreadIdByDay.keys()) {
|
|
170
|
+
if (day < cutOffDay) {
|
|
171
|
+
errorByThreadIdByDay.delete(day);
|
|
172
|
+
console.log(`Cleared old day ${formatDateTime(day)}`);
|
|
71
173
|
}
|
|
72
|
-
console.log();
|
|
73
|
-
console.log();
|
|
74
174
|
}
|
|
75
|
-
|
|
76
|
-
|
|
175
|
+
}
|
|
176
|
+
errorWatcherBase.watch(async (objs) => {
|
|
177
|
+
clearOldDays();
|
|
178
|
+
objs = await suppressionList.filterObjsToNonSuppressed(objs);
|
|
179
|
+
objs = objs.filter(x => !isDuplicate(x));
|
|
180
|
+
if (objs.length === 0) return;
|
|
181
|
+
// The oldest first as they are most likely the cause.
|
|
182
|
+
sort(objs, x => -x.time);
|
|
183
|
+
void sendIMs(objs);
|
|
184
|
+
console.log();
|
|
185
|
+
console.log();
|
|
186
|
+
console.log(`Received ${objs.length} recent errors at ${formatDateTime(Date.now())}`);
|
|
187
|
+
for (let obj of objs) {
|
|
188
|
+
console.log(` ${obj.param0}`);
|
|
189
|
+
}
|
|
190
|
+
console.log();
|
|
191
|
+
console.log();
|
|
192
|
+
});
|
|
193
|
+
|
|
77
194
|
}
|
|
78
195
|
|
|
79
196
|
async function main() {
|
|
@@ -4,24 +4,7 @@ Very small amount of data
|
|
|
4
4
|
https://127-0-0-1.querysubtest.com:7007/?hot&enableLogs&page=login&filter=%22431%22&showingmanagement&endTime=1755140880000&startTime=1754950020000&managementpage=LogViewer2
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- Create a dedicated entry point which acts like a client of the HTTP server, using RecentErrorControllers.getRecentErrors
|
|
9
|
-
- Getting it working in a script will be interesting, but... in theory it should just work?
|
|
10
|
-
- Just for new errors
|
|
11
|
-
- Using backblaze to track when we send it, so we can heavily limit IMs and email
|
|
12
|
-
- IM api key tracked in secrets (like email api key)
|
|
13
|
-
- Once we get it working, deploy to production
|
|
14
|
-
|
|
15
|
-
6) IM + email digests (daily / weekly?)
|
|
16
|
-
- a very short digest for the instant message which then links to a page on the site with a larger digest
|
|
17
|
-
- which has tabs, and each part in the instant message links to the correct tab
|
|
18
|
-
- Augments the error notifications entry point, having it also queue stuff up for digests.
|
|
19
|
-
- Some notifications will never be immediate and will always be only in digests.
|
|
20
|
-
- For now this will just be for:
|
|
21
|
-
- non-suppressed errors
|
|
22
|
-
- suppressed errors
|
|
23
|
-
|
|
24
|
-
|
|
7
|
+
AFTER digests, go back to adding application code, as the framework is getting boring...
|
|
25
8
|
|
|
26
9
|
5) Life cycle analyzer
|
|
27
10
|
- Implement regular range lifecycles first (matching an === object field)
|
|
@@ -91,8 +74,16 @@ Make sure we check our life cycles for nodes being added and removed to make sur
|
|
|
91
74
|
|
|
92
75
|
Check the startup lifecycle to make sure we can detect the nodes pretty fast and in parallel, instead of serial
|
|
93
76
|
|
|
94
|
-
|
|
77
|
+
11) Take all of the errors that we are ignoring and use the life cycles to detect why they're happening. A lot of them really shouldn't be happening.
|
|
78
|
+
- Receiving values from different authorities and the ones we're watching is weird. Why does that keep happening?
|
|
79
|
+
- And we keep running into audit mismatches? Why does that keep happening? Is it only because of our local development server?
|
|
80
|
+
|
|
81
|
+
DEBUG: Deploy hash updates.
|
|
82
|
+
- Forced refresh now, and then immediately refreshing is STILL not giving us the latest code. Even though we waited for everything to reload the UI, which took forever.
|
|
83
|
+
- It's probably an issue with the routing information being out of date, I think it's cached in Cloudflare. We could at least use life cycles to verify the values we have, and then if they're different than the values in the client, then I guess it must be in Cloudflare. We can also verify our timing, as I'm pretty sure we're supposed to be waiting for the cloud flare values to update, and if we're not, then that's a problem.
|
|
95
84
|
|
|
85
|
+
DEBUG: Suppression creation propagation
|
|
86
|
+
- It didn't propagate to all the servers?
|
|
96
87
|
|
|
97
88
|
SPECIAL UI links for certain errors in log view
|
|
98
89
|
- Probably dynamically created, based on contents of log
|
|
@@ -90,6 +90,17 @@ export async function registerManagementPages2(config: {
|
|
|
90
90
|
componentName: "LogViewer2",
|
|
91
91
|
getModule: () => import("./logs/LogViewer2"),
|
|
92
92
|
});
|
|
93
|
+
inputPages.push({
|
|
94
|
+
title: "Error Digests",
|
|
95
|
+
componentName: "ErrorDigestPage",
|
|
96
|
+
controllerName: "ErrorDigestController",
|
|
97
|
+
getModule: () => import("./logs/errorNotifications/ErrorDigestPage"),
|
|
98
|
+
});
|
|
99
|
+
inputPages.push({
|
|
100
|
+
title: "Security",
|
|
101
|
+
componentName: "SecurityPage",
|
|
102
|
+
getModule: () => import("../user-implementation/SecurityPage"),
|
|
103
|
+
});
|
|
93
104
|
inputPages.push({
|
|
94
105
|
title: "Audit Paths",
|
|
95
106
|
componentName: "AuditLogPage",
|
|
@@ -176,22 +187,29 @@ export async function registerManagementPages2(config: {
|
|
|
176
187
|
// Wait, so the import system knows the modules are async imports
|
|
177
188
|
await delay(0);
|
|
178
189
|
for (let page of inputPages) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
190
|
+
try {
|
|
191
|
+
// NOTE: If we split this into a module for component/controller, we need to make sure we
|
|
192
|
+
// import both serverside, so we can whitelist them for import clientside.
|
|
193
|
+
let mod = await page.getModule();
|
|
194
|
+
if (!page.controllerName) continue;
|
|
195
|
+
if (!(page.controllerName in mod)) {
|
|
196
|
+
console.error(`Controller ${page.controllerName} not found in module`, mod);
|
|
197
|
+
throw new Error(`Controller ${page.controllerName} not found in module`);
|
|
198
|
+
}
|
|
199
|
+
let controller = mod[page.controllerName] as SocketRegistered;
|
|
200
|
+
if ((controller as any)?.__baseController) {
|
|
201
|
+
controller = (controller as any).__baseController;
|
|
202
|
+
}
|
|
203
|
+
if (!controller) {
|
|
204
|
+
throw new Error(`Controller ${page.controllerName} not found in module`);
|
|
205
|
+
}
|
|
206
|
+
if (!controller._classGuid) {
|
|
207
|
+
throw new Error(`Controller ${page.controllerName} does not have a class guid`);
|
|
208
|
+
}
|
|
209
|
+
SocketFunction.expose(controller);
|
|
210
|
+
} catch (e: any) {
|
|
211
|
+
console.error(`Error when registering management page ${page.controllerName} in ${page.componentName}: ${e.stack}`);
|
|
193
212
|
}
|
|
194
|
-
SocketFunction.expose(controller);
|
|
195
213
|
}
|
|
196
214
|
} else {
|
|
197
215
|
for (let page of inputPages) {
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { httpsRequest } from "../https";
|
|
2
|
+
|
|
3
|
+
export interface DiscordEmbedFooter {
|
|
4
|
+
text: string;
|
|
5
|
+
icon_url?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface DiscordEmbedImage {
|
|
9
|
+
url: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface DiscordEmbedThumbnail {
|
|
13
|
+
url: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DiscordEmbedVideo {
|
|
17
|
+
url: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface DiscordEmbedProvider {
|
|
21
|
+
name?: string;
|
|
22
|
+
url?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface DiscordEmbedAuthor {
|
|
26
|
+
name: string;
|
|
27
|
+
url?: string;
|
|
28
|
+
icon_url?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DiscordEmbedField {
|
|
32
|
+
name: string;
|
|
33
|
+
value: string;
|
|
34
|
+
inline?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DiscordEmbed {
|
|
38
|
+
title?: string;
|
|
39
|
+
type?: "rich" | "image" | "video" | "gifv" | "article" | "link";
|
|
40
|
+
description?: string;
|
|
41
|
+
url?: string;
|
|
42
|
+
timestamp?: string; // ISO8601 timestamp
|
|
43
|
+
color?: number; // integer color value
|
|
44
|
+
footer?: DiscordEmbedFooter;
|
|
45
|
+
image?: DiscordEmbedImage;
|
|
46
|
+
thumbnail?: DiscordEmbedThumbnail;
|
|
47
|
+
video?: DiscordEmbedVideo;
|
|
48
|
+
provider?: DiscordEmbedProvider;
|
|
49
|
+
author?: DiscordEmbedAuthor;
|
|
50
|
+
fields?: DiscordEmbedField[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface DiscordAllowedMentions {
|
|
54
|
+
parse?: ("roles" | "users" | "everyone")[];
|
|
55
|
+
roles?: string[];
|
|
56
|
+
users?: string[];
|
|
57
|
+
replied_user?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DiscordAttachment {
|
|
61
|
+
id: string;
|
|
62
|
+
filename: string;
|
|
63
|
+
description?: string;
|
|
64
|
+
content_type?: string;
|
|
65
|
+
size: number;
|
|
66
|
+
url: string;
|
|
67
|
+
proxy_url: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface DiscordEmoji {
|
|
71
|
+
id?: string;
|
|
72
|
+
name?: string;
|
|
73
|
+
animated?: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface DiscordSelectOption {
|
|
77
|
+
label: string;
|
|
78
|
+
value: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
emoji?: DiscordEmoji;
|
|
81
|
+
default?: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface DiscordButtonComponent {
|
|
85
|
+
type: 2; // Button component type
|
|
86
|
+
style: 1 | 2 | 3 | 4 | 5; // Primary, Secondary, Success, Danger, Link
|
|
87
|
+
label?: string;
|
|
88
|
+
emoji?: DiscordEmoji;
|
|
89
|
+
custom_id?: string; // Required for non-link buttons
|
|
90
|
+
url?: string; // Required for link buttons
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface DiscordSelectMenuComponent {
|
|
95
|
+
type: 3; // String select menu component type
|
|
96
|
+
custom_id: string;
|
|
97
|
+
options: DiscordSelectOption[];
|
|
98
|
+
placeholder?: string;
|
|
99
|
+
min_values?: number;
|
|
100
|
+
max_values?: number;
|
|
101
|
+
disabled?: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface DiscordUserSelectMenuComponent {
|
|
105
|
+
type: 5; // User select menu component type
|
|
106
|
+
custom_id: string;
|
|
107
|
+
placeholder?: string;
|
|
108
|
+
min_values?: number;
|
|
109
|
+
max_values?: number;
|
|
110
|
+
disabled?: boolean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface DiscordRoleSelectMenuComponent {
|
|
114
|
+
type: 6; // Role select menu component type
|
|
115
|
+
custom_id: string;
|
|
116
|
+
placeholder?: string;
|
|
117
|
+
min_values?: number;
|
|
118
|
+
max_values?: number;
|
|
119
|
+
disabled?: boolean;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface DiscordMentionableSelectMenuComponent {
|
|
123
|
+
type: 7; // Mentionable (users + roles) select menu component type
|
|
124
|
+
custom_id: string;
|
|
125
|
+
placeholder?: string;
|
|
126
|
+
min_values?: number;
|
|
127
|
+
max_values?: number;
|
|
128
|
+
disabled?: boolean;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface DiscordChannelSelectMenuComponent {
|
|
132
|
+
type: 8; // Channel select menu component type
|
|
133
|
+
custom_id: string;
|
|
134
|
+
placeholder?: string;
|
|
135
|
+
min_values?: number;
|
|
136
|
+
max_values?: number;
|
|
137
|
+
disabled?: boolean;
|
|
138
|
+
channel_types?: number[]; // Array of channel type integers to filter by
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface DiscordTextInputComponent {
|
|
142
|
+
type: 4; // Text input component type
|
|
143
|
+
custom_id: string;
|
|
144
|
+
style: 1 | 2; // Short (1) or Paragraph (2)
|
|
145
|
+
label: string;
|
|
146
|
+
min_length?: number;
|
|
147
|
+
max_length?: number;
|
|
148
|
+
required?: boolean;
|
|
149
|
+
value?: string;
|
|
150
|
+
placeholder?: string;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type DiscordComponent =
|
|
154
|
+
| DiscordButtonComponent
|
|
155
|
+
| DiscordSelectMenuComponent
|
|
156
|
+
| DiscordUserSelectMenuComponent
|
|
157
|
+
| DiscordRoleSelectMenuComponent
|
|
158
|
+
| DiscordMentionableSelectMenuComponent
|
|
159
|
+
| DiscordChannelSelectMenuComponent
|
|
160
|
+
| DiscordTextInputComponent;
|
|
161
|
+
|
|
162
|
+
export interface DiscordActionRow {
|
|
163
|
+
type: 1; // Action row component type
|
|
164
|
+
components: DiscordComponent[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface DiscordWebhookMessage {
|
|
168
|
+
content?: string;
|
|
169
|
+
username?: string;
|
|
170
|
+
avatar_url?: string;
|
|
171
|
+
tts?: boolean;
|
|
172
|
+
embeds?: DiscordEmbed[];
|
|
173
|
+
allowed_mentions?: DiscordAllowedMentions;
|
|
174
|
+
components?: DiscordActionRow[];
|
|
175
|
+
files?: any[]; // File attachments - these are handled differently in multipart requests
|
|
176
|
+
payload_json?: string;
|
|
177
|
+
attachments?: DiscordAttachment[];
|
|
178
|
+
flags?: number;
|
|
179
|
+
thread_name?: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function sendDiscordMessage(config: {
|
|
183
|
+
webhookURL: string;
|
|
184
|
+
message: DiscordWebhookMessage | string;
|
|
185
|
+
}) {
|
|
186
|
+
const { webhookURL, message } = config;
|
|
187
|
+
|
|
188
|
+
const payload: DiscordWebhookMessage = typeof message === "string"
|
|
189
|
+
? { content: message }
|
|
190
|
+
: message;
|
|
191
|
+
|
|
192
|
+
await httpsRequest(
|
|
193
|
+
webhookURL,
|
|
194
|
+
Buffer.from(JSON.stringify(payload)),
|
|
195
|
+
"POST",
|
|
196
|
+
false,
|
|
197
|
+
{
|
|
198
|
+
headers: {
|
|
199
|
+
"Content-Type": "application/json",
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -8,7 +8,7 @@ import { renderToString } from "../library-components/renderToString";
|
|
|
8
8
|
|
|
9
9
|
export async function sendEmail_postmark(config: {
|
|
10
10
|
apiKey: string;
|
|
11
|
-
to: string;
|
|
11
|
+
to: string[];
|
|
12
12
|
from: string;
|
|
13
13
|
subject: string;
|
|
14
14
|
contents: preact.VNode;
|
|
@@ -18,13 +18,13 @@ export async function sendEmail_postmark(config: {
|
|
|
18
18
|
if (Querysub.isInSyncedCall()) {
|
|
19
19
|
throw new Error("sendEmail_sendgrid should not be called in a synced call, as this might result in multiple sends. Instead, use Querysub.onCommitFinished to call after the synced call");
|
|
20
20
|
}
|
|
21
|
-
console.log(`${magenta("Sending email")} to ${green(config.to)} with subject ${config.subject}`);
|
|
21
|
+
console.log(`${magenta("Sending email")} to ${green(config.to.join(", "))} with subject ${config.subject}`);
|
|
22
22
|
let htmlContent = renderToString(config.contents);
|
|
23
23
|
await httpsRequest(
|
|
24
24
|
"https://api.postmarkapp.com/email",
|
|
25
25
|
Buffer.from(JSON.stringify({
|
|
26
26
|
From: config.from,
|
|
27
|
-
To: config.to,
|
|
27
|
+
To: config.to.join(","),
|
|
28
28
|
Subject: config.subject,
|
|
29
29
|
HtmlBody: htmlContent,
|
|
30
30
|
})),
|
package/src/fs.ts
CHANGED
|
@@ -69,4 +69,13 @@ export async function* readDirRecursive(dir: string): AsyncGenerator<string> {
|
|
|
69
69
|
} catch { }
|
|
70
70
|
}
|
|
71
71
|
} catch { }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function fsExistsAsync(path: string) {
|
|
75
|
+
try {
|
|
76
|
+
await fs.promises.stat(path);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
72
81
|
}
|
|
@@ -55,6 +55,7 @@ export class SocketChannel<T> {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
private watchAllNodes = lazy(async () => {
|
|
58
|
+
// NOTE: By watching instead of having nodes broadcast, it naturally prevents non-public servers from sending messages to public servers. This is really nice, and helps keep non-public servers (development servers), actually non-public...
|
|
58
59
|
watchNodeIds((nodeIds) => {
|
|
59
60
|
for (let nodeId of nodeIds) {
|
|
60
61
|
void errorToUndefinedSilent(this.controller.nodes[nodeId]._internal_watchMessages());
|
|
@@ -68,4 +69,12 @@ export class SocketChannel<T> {
|
|
|
68
69
|
this.localWatchers.delete(callback);
|
|
69
70
|
};
|
|
70
71
|
}
|
|
72
|
+
// NOTE: We also get notifications for watching on all nodes, which should be fine...
|
|
73
|
+
public async watchSingleNode(nodeId: string, callback: (message: T) => void) {
|
|
74
|
+
await this.controller.nodes[nodeId]._internal_watchMessages();
|
|
75
|
+
this.localWatchers.add(callback);
|
|
76
|
+
return () => {
|
|
77
|
+
this.localWatchers.delete(callback);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
71
80
|
}
|