@pmndrs/pine 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +180 -2
  3. package/bin/pine.js +27 -0
  4. package/docs/SKILL.md +189 -0
  5. package/docs/references/agents.md +368 -0
  6. package/docs/references/coding.md +126 -0
  7. package/docs/references/configuration.md +229 -0
  8. package/docs/references/events.md +263 -0
  9. package/docs/references/mcp.md +257 -0
  10. package/docs/references/operations.md +280 -0
  11. package/docs/references/overview.md +186 -0
  12. package/docs/references/security.md +489 -0
  13. package/docs/references/setup.md +181 -0
  14. package/docs/references/sources.md +768 -0
  15. package/docs/references/stack.md +105 -0
  16. package/docs/references/tui.md +348 -0
  17. package/examples/home/.env.example +5 -0
  18. package/examples/home/USER.md +4 -0
  19. package/examples/home/agents/main/agent.json +4 -0
  20. package/examples/home/agents/main/workspace/AGENTS.md +14 -0
  21. package/examples/home/agents/main/workspace/MEMORY.md +4 -0
  22. package/examples/home/agents/main/workspace/SOUL.md +4 -0
  23. package/examples/home/models.json +62 -0
  24. package/examples/home/pine.json +30 -0
  25. package/examples/home/skills/README.txt +7 -0
  26. package/examples/launchd/com.pine.daemon.plist +32 -0
  27. package/mcp/browser.d.ts +34 -0
  28. package/mcp/browser.js +228 -0
  29. package/mcp/commands.d.ts +20 -0
  30. package/mcp/commands.js +88 -0
  31. package/mcp/lib.d.ts +13 -0
  32. package/mcp/lib.js +50 -0
  33. package/mcp/web.d.ts +6 -0
  34. package/mcp/web.js +140 -0
  35. package/package.json +32 -4
  36. package/src/agent-admin.d.ts +24 -0
  37. package/src/agent-admin.js +55 -0
  38. package/src/agents.d.ts +343 -0
  39. package/src/agents.js +1587 -0
  40. package/src/cli.d.ts +2 -0
  41. package/src/cli.js +526 -0
  42. package/src/config.d.ts +351 -0
  43. package/src/config.js +514 -0
  44. package/src/dev/claude-model.d.ts +28 -0
  45. package/src/dev/claude-model.js +174 -0
  46. package/src/env-file.d.ts +4 -0
  47. package/src/env-file.js +40 -0
  48. package/src/http.d.ts +46 -0
  49. package/src/http.js +407 -0
  50. package/src/limiter.d.ts +32 -0
  51. package/src/limiter.js +84 -0
  52. package/src/login.d.ts +21 -0
  53. package/src/login.js +126 -0
  54. package/src/manual.d.ts +61 -0
  55. package/src/manual.js +425 -0
  56. package/src/mcp-config.d.ts +40 -0
  57. package/src/mcp-config.js +132 -0
  58. package/src/net.d.ts +36 -0
  59. package/src/net.js +122 -0
  60. package/src/pine.d.ts +32 -0
  61. package/src/pine.js +142 -0
  62. package/src/router.d.ts +56 -0
  63. package/src/router.js +164 -0
  64. package/src/sandbox-fs.d.ts +27 -0
  65. package/src/sandbox-fs.js +172 -0
  66. package/src/security.d.ts +121 -0
  67. package/src/security.js +363 -0
  68. package/src/skills.d.ts +67 -0
  69. package/src/skills.js +163 -0
  70. package/src/source-types.d.ts +61 -0
  71. package/src/source-types.js +1 -0
  72. package/src/sources/command.d.ts +4 -0
  73. package/src/sources/command.js +77 -0
  74. package/src/sources/folder.d.ts +6 -0
  75. package/src/sources/folder.js +116 -0
  76. package/src/sources/ftp.d.ts +4 -0
  77. package/src/sources/ftp.js +307 -0
  78. package/src/sources/http.d.ts +4 -0
  79. package/src/sources/http.js +201 -0
  80. package/src/sources/mqtt.d.ts +18 -0
  81. package/src/sources/mqtt.js +97 -0
  82. package/src/sources/telegram.d.ts +22 -0
  83. package/src/sources/telegram.js +269 -0
  84. package/src/sources/webhook.d.ts +4 -0
  85. package/src/sources/webhook.js +52 -0
  86. package/src/sources.d.ts +339 -0
  87. package/src/sources.js +955 -0
  88. package/src/store.d.ts +89 -0
  89. package/src/store.js +251 -0
  90. package/src/timers.d.ts +83 -0
  91. package/src/timers.js +318 -0
  92. package/src/tools.d.ts +6 -0
  93. package/src/tools.js +777 -0
  94. package/src/tui/app.d.ts +209 -0
  95. package/src/tui/app.js +2223 -0
  96. package/src/tui/client.d.ts +178 -0
  97. package/src/tui/client.js +116 -0
  98. package/src/tui/code-pane.d.ts +51 -0
  99. package/src/tui/code-pane.js +398 -0
  100. package/src/tui/highlight.d.ts +8 -0
  101. package/src/tui/highlight.js +86 -0
  102. package/src/tui/picture.d.ts +141 -0
  103. package/src/tui/picture.js +571 -0
  104. package/src/tui/run.d.ts +8 -0
  105. package/src/tui/run.js +97 -0
  106. package/src/tui/theme.d.ts +32 -0
  107. package/src/tui/theme.js +128 -0
  108. package/src/types.d.ts +124 -0
  109. package/src/types.js +1 -0
  110. package/src/update.d.ts +25 -0
  111. package/src/update.js +110 -0
  112. package/src/util.d.ts +32 -0
  113. package/src/util.js +116 -0
  114. package/src/watching.d.ts +20 -0
  115. package/src/watching.js +68 -0
  116. package/src/workspace-view.d.ts +38 -0
  117. package/src/workspace-view.js +160 -0
@@ -0,0 +1,307 @@
1
+ import fs from "node:fs";
2
+ import net from "node:net";
3
+ import path from "node:path";
4
+ import { timingSafeEqual } from "../security.js";
5
+ import { Type } from "typebox";
6
+ import { errText } from "../util.js";
7
+ /**
8
+ * An FTP drop: a small FTP server that accepts uploads and nothing else. For cameras, doorbells,
9
+ * scanners and other devices whose only way of sending a file is "upload to FTP server".
10
+ *
11
+ * options: { port, user, password, host?, allow?, pasvPorts?, maxFileMb?, keepDays? }
12
+ * port, user, password what you enter in the device. The password must have 8 characters or more.
13
+ * host address to listen on. Default "0.0.0.0": devices on your network have to reach it.
14
+ * allow only these client addresses, as prefixes: ["192.168.1.40", "192.168.2."]
15
+ * pasvPorts [from, to]: ports for the data connections, if a firewall needs to know them
16
+ * maxFileMb largest file accepted (default 50)
17
+ * keepDays received files are deleted after this many days (default 7)
18
+ *
19
+ * event "upload": { file, path, size, from, images? } file is the name the device gave it
20
+ *
21
+ * It speaks plain FTP, as these devices do: the password crosses your network unencrypted. It can
22
+ * only receive. Nothing can be listed, read, changed or deleted through it, and every file lands
23
+ * under a name of pine's choosing inside the source's own folder.
24
+ */
25
+ const TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".gif": "image/gif" };
26
+ export const type = {
27
+ summary: "an FTP drop: a small server that accepts uploads and nothing else. For cameras, doorbells and scanners whose only way of sending a file is \"upload to FTP server\"",
28
+ options: Type.Object({
29
+ port: Type.Number({ description: "port to listen on, e.g. 2121" }),
30
+ user: Type.Optional(Type.String({ description: 'user name the device logs in with. Default "device"' })),
31
+ password: Type.Optional(Type.String({ description: "password the device logs in with, 8 characters or more. Leave it out and pine makes one", secret: true })),
32
+ host: Type.Optional(Type.String({ description: 'address to listen on. Default "0.0.0.0", so that devices on the network reach it' })),
33
+ allow: Type.Optional(Type.Array(Type.String(), { description: 'only these client addresses, as prefixes: ["192.168.1.40", "192.168.2."]' })),
34
+ pasvPorts: Type.Optional(Type.Array(Type.Number(), { description: "[from, to]: ports for the data connections, if a firewall needs to know them" })),
35
+ maxFileMb: Type.Optional(Type.Number({ description: "largest file accepted, in MB. Default 50" })),
36
+ keepDays: Type.Optional(Type.Number({ description: "received files are deleted after this many days. Default 7" })),
37
+ }, { additionalProperties: false }),
38
+ plan(o, setup) {
39
+ if (!Number.isInteger(o.port) || o.port < 1 || o.port > 65535)
40
+ throw new Error(`"port" must be a port number, for example 2121`);
41
+ if (o.password !== undefined && String(o.password).length < 8)
42
+ throw new Error("the password must have 8 characters or more");
43
+ if (o.pasvPorts && o.pasvPorts.length !== 2)
44
+ throw new Error(`"pasvPorts" is two numbers: [from, to]`);
45
+ const user = o.user ?? "device";
46
+ return {
47
+ options: { ...o, user, password: o.password ?? setup.random(9) },
48
+ does: [`open an upload-only FTP server on port ${o.port} of this computer for the user "${user}"${o.password ? "" : " with a password made now"}${o.allow?.length ? `, for the addresses ${o.allow.join(", ")}` : ""}`],
49
+ opens: true,
50
+ tell: `The device uploads to port ${o.port} as user "${user}". The password is in pine's .env file as ${setup.envName("password")}, where your owner reads it.`,
51
+ };
52
+ },
53
+ };
54
+ export default async function ftp(source) {
55
+ const o = source.options;
56
+ const port = Number(o.port);
57
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
58
+ throw new Error(`needs "port" (for example 2121)`);
59
+ const user = String(o.user ?? "");
60
+ const password = String(o.password ?? "");
61
+ if (!user || password.length < 8)
62
+ throw new Error(`needs "user" and a "password" of 8 characters or more: without them anyone on the network could hand files to your agents`);
63
+ source.secret(password);
64
+ const host = String(o.host ?? "0.0.0.0");
65
+ const allow = (Array.isArray(o.allow) ? o.allow : []).map(String);
66
+ const range = Array.isArray(o.pasvPorts) && o.pasvPorts.length === 2 ? [Number(o.pasvPorts[0]), Number(o.pasvPorts[1])] : undefined;
67
+ const maxBytes = Number(o.maxFileMb ?? 50) * 1024 * 1024;
68
+ const keepMs = Number(o.keepDays ?? 7) * 86_400_000;
69
+ const dir = path.join(source.dataDir, "files");
70
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
71
+ source.media(dir);
72
+ const prune = () => {
73
+ for (const name of fs.readdirSync(dir)) {
74
+ const file = path.join(dir, name);
75
+ try {
76
+ if (Date.now() - fs.statSync(file).mtimeMs > keepMs)
77
+ fs.rmSync(file, { force: true });
78
+ }
79
+ catch {
80
+ // gone already
81
+ }
82
+ }
83
+ };
84
+ prune();
85
+ const pruner = setInterval(prune, 6 * 3_600_000);
86
+ pruner.unref?.();
87
+ const plain = (addr) => (addr ?? "").replace(/^::ffff:/, "");
88
+ const open = new Set();
89
+ let counter = 0;
90
+ const server = net.createServer((control) => {
91
+ const remote = plain(control.remoteAddress);
92
+ if (open.size >= 20 || (allow.length && !allow.some((a) => remote === a || (a.endsWith(".") && remote.startsWith(a)))))
93
+ return void control.destroy();
94
+ open.add(control);
95
+ control.setTimeout(120_000, () => control.destroy());
96
+ control.setEncoding("utf8");
97
+ let name = "";
98
+ let authed = false;
99
+ let failures = 0;
100
+ let cwd = "/";
101
+ let passive;
102
+ let data;
103
+ const say = (line) => control.writable && control.write(`${line}\r\n`);
104
+ const closePassive = () => {
105
+ passive?.close();
106
+ passive = undefined;
107
+ data?.then((s) => s.destroy()).catch(() => { });
108
+ data = undefined;
109
+ };
110
+ control.on("close", () => {
111
+ open.delete(control);
112
+ closePassive();
113
+ });
114
+ control.on("error", () => { });
115
+ /** Open a port for the next transfer. Only the device on the control connection may connect to it. */
116
+ const listen = async () => {
117
+ closePassive();
118
+ const s = net.createServer();
119
+ passive = s;
120
+ data = new Promise((resolve, reject) => {
121
+ const timer = setTimeout(() => reject(new Error("no data connection")), 20_000);
122
+ s.on("connection", (sock) => {
123
+ if (plain(sock.remoteAddress) !== remote)
124
+ return void sock.destroy();
125
+ clearTimeout(timer);
126
+ s.close();
127
+ resolve(sock);
128
+ });
129
+ s.on("close", () => clearTimeout(timer));
130
+ });
131
+ data.catch(() => { });
132
+ const tryPort = (p) => new Promise((resolve, reject) => {
133
+ s.once("error", reject);
134
+ s.listen(p, host === "0.0.0.0" ? plain(control.localAddress) : host, () => (s.removeListener("error", reject), resolve(s.address().port)));
135
+ });
136
+ if (!range)
137
+ return tryPort(0);
138
+ for (let p = range[0]; p <= range[1]; p++) {
139
+ try {
140
+ return await tryPort(p);
141
+ }
142
+ catch {
143
+ // taken; try the next one
144
+ }
145
+ }
146
+ throw new Error("no free data port");
147
+ };
148
+ const receive = async (given) => {
149
+ if (!data)
150
+ return say("425 Use PASV first");
151
+ const wanted = path.posix.join(cwd, given);
152
+ const clean = path.basename(given).replace(/[^A-Za-z0-9._-]+/g, "_").replace(/^\.+/, "").slice(-80) || "file";
153
+ const target = path.join(dir, `${Date.now().toString(36)}${(counter++).toString(36)}-${clean}`);
154
+ say("150 Send it");
155
+ let sock;
156
+ try {
157
+ sock = await data;
158
+ }
159
+ catch {
160
+ return say("425 No data connection");
161
+ }
162
+ data = undefined;
163
+ let size = 0;
164
+ let tooBig = false;
165
+ // "wx": the name is new; nothing that exists is ever written over.
166
+ const out = fs.createWriteStream(target, { flags: "wx", mode: 0o600 });
167
+ sock.on("data", (chunk) => {
168
+ size += chunk.length;
169
+ if (size > maxBytes) {
170
+ tooBig = true;
171
+ sock.destroy();
172
+ }
173
+ });
174
+ sock.pipe(out);
175
+ await new Promise((resolve) => {
176
+ out.on("close", resolve);
177
+ out.on("error", () => resolve());
178
+ // A connection that is cut (by the device, or here because the file got too large) never
179
+ // says "end"; the file is closed all the same, so the transfer gets its answer.
180
+ sock.on("close", () => out.end());
181
+ sock.on("error", () => out.end());
182
+ });
183
+ if (tooBig || size === 0) {
184
+ fs.rmSync(target, { force: true });
185
+ return say(tooBig ? "552 File too large" : "451 Nothing received");
186
+ }
187
+ say("226 Stored");
188
+ const mimeType = TYPES[path.extname(clean).toLowerCase()];
189
+ try {
190
+ source.emit("upload", { file: wanted, path: target, size, from: remote, images: mimeType && size < 10 * 1024 * 1024 ? [{ path: target, mimeType }] : undefined });
191
+ }
192
+ catch (e) {
193
+ source.log.warn("could not report an upload", errText(e));
194
+ }
195
+ };
196
+ const handle = async (line) => {
197
+ const [verb, ...rest] = line.trim().split(" ");
198
+ const arg = rest.join(" ");
199
+ const cmd = verb.toUpperCase();
200
+ if (cmd === "QUIT")
201
+ return void (say("221 Bye"), control.end());
202
+ if (cmd === "USER")
203
+ return void ((name = arg), say("331 Password"));
204
+ if (cmd === "PASS") {
205
+ authed = timingSafeEqual(name, user) && timingSafeEqual(arg, password);
206
+ if (authed)
207
+ return say("230 Welcome");
208
+ if (++failures >= 3)
209
+ return void (say("530 Bye"), control.end());
210
+ return say("530 Login incorrect");
211
+ }
212
+ if (cmd === "SYST")
213
+ return say("215 UNIX Type: L8");
214
+ if (cmd === "FEAT")
215
+ return say("211-Features:\r\n PASV\r\n EPSV\r\n UTF8\r\n211 End");
216
+ if (!authed)
217
+ return say("530 Log in first");
218
+ switch (cmd) {
219
+ case "OPTS":
220
+ case "TYPE":
221
+ case "MODE":
222
+ case "STRU":
223
+ case "NOOP":
224
+ case "ALLO":
225
+ return say("200 OK");
226
+ case "PWD":
227
+ case "XPWD":
228
+ return say(`257 "${cwd}"`);
229
+ case "CWD":
230
+ case "XCWD":
231
+ cwd = path.posix.resolve(cwd, arg || "/");
232
+ return say("250 OK");
233
+ case "CDUP":
234
+ cwd = path.posix.resolve(cwd, "..");
235
+ return say("250 OK");
236
+ // Folders exist only as part of the name a file is reported under.
237
+ case "MKD":
238
+ case "XMKD":
239
+ return say(`257 "${path.posix.resolve(cwd, arg)}"`);
240
+ case "PASV": {
241
+ const p = await listen();
242
+ const ip = plain(control.localAddress).split(".");
243
+ if (ip.length !== 4)
244
+ return say("425 Use EPSV");
245
+ return say(`227 Entering Passive Mode (${ip.join(",")},${p >> 8},${p & 255})`);
246
+ }
247
+ case "EPSV":
248
+ return say(`229 Entering Extended Passive Mode (|||${await listen()}|)`);
249
+ case "STOR":
250
+ case "APPE":
251
+ case "STOU":
252
+ return receive(arg || "file");
253
+ case "LIST":
254
+ case "NLST":
255
+ case "MLSD": {
256
+ // Always empty: this server gives nothing back.
257
+ if (!data)
258
+ return say("425 Use PASV first");
259
+ say("150 Here it comes");
260
+ const sock = await data.catch(() => undefined);
261
+ data = undefined;
262
+ sock?.end();
263
+ return say("226 Done");
264
+ }
265
+ // Some devices upload under a temporary name and rename afterwards. The file is already reported; say yes.
266
+ case "RNFR":
267
+ return say("350 Ready");
268
+ case "RNTO":
269
+ case "DELE":
270
+ case "RMD":
271
+ case "SITE":
272
+ return say("250 OK");
273
+ case "SIZE":
274
+ case "MDTM":
275
+ case "RETR":
276
+ return say("550 Not available");
277
+ default:
278
+ return say("502 Not implemented");
279
+ }
280
+ };
281
+ let buffer = "";
282
+ let busy = Promise.resolve();
283
+ control.on("data", (chunk) => {
284
+ buffer += chunk;
285
+ if (buffer.length > 8192)
286
+ return void control.destroy();
287
+ let i;
288
+ while ((i = buffer.indexOf("\n")) >= 0) {
289
+ const line = buffer.slice(0, i).replace(/\r$/, "");
290
+ buffer = buffer.slice(i + 1);
291
+ busy = busy.then(() => handle(line)).catch((e) => (source.log.debug("ftp command failed", errText(e)), say("451 Failed")));
292
+ }
293
+ });
294
+ say("220 pine file drop");
295
+ });
296
+ await new Promise((resolve, reject) => {
297
+ server.once("error", reject);
298
+ server.listen(port, host, () => (server.removeListener("error", reject), resolve()));
299
+ });
300
+ source.onStop(() => new Promise((resolve) => {
301
+ clearInterval(pruner);
302
+ for (const s of open)
303
+ s.destroy();
304
+ server.close(() => resolve());
305
+ }));
306
+ source.log.info(`accepting uploads on ${host}:${port}`);
307
+ }
@@ -0,0 +1,4 @@
1
+ import type { SourceType } from "../source-types.ts";
2
+ import type { SourceApi } from "../sources.ts";
3
+ export declare const type: SourceType;
4
+ export default function http(source: SourceApi): void;
@@ -0,0 +1,201 @@
1
+ import net from "node:net";
2
+ import { Type } from "typebox";
3
+ import { fetch } from "undici";
4
+ import { isLocalName, isPrivateAddress, publicOnlyAgent } from "../net.js";
5
+ import { errText, getPath, parseDuration } from "../util.js";
6
+ import { clip, found, harmless, lineDiff, pageText, pattern, shown } from "../watching.js";
7
+ /**
8
+ * A web address, looked at again and again: reports when what it answers changes, and when it
9
+ * stops or starts answering. For pages and services that cannot call you: a price, a version, a
10
+ * status page, a device with a state page on your network.
11
+ *
12
+ * options: { url, every?, path?, match?, headers?, token?, tokenHeader?, local? }
13
+ * every how often to look ("5m" by default)
14
+ * path for a JSON answer: watch only this field ("data.price")
15
+ * match for a page or text: watch only what this regular expression finds
16
+ * headers headers to send
17
+ * token sent as "Authorization: Bearer <token>", or as it is in the header `tokenHeader`
18
+ * local the address is on the owner's own network, under a name that does not say so
19
+ *
20
+ * event "changed": { url, value, previous } with path or match
21
+ * { url, added, removed, more? } without: the lines that are new and gone
22
+ * event "down": { url, error, looks } after three failed looks in a row
23
+ * event "up": { url } when it answers again
24
+ *
25
+ * The first look is the starting point and reports nothing.
26
+ */
27
+ const MIN = 30_000;
28
+ const DOWN_AFTER = 3;
29
+ const MAX_BODY = 1024 * 1024;
30
+ const TIMEOUT = 20_000;
31
+ /**
32
+ * Whether an address says by itself that it is on this computer or the local network. Such an
33
+ * address the owner confirmed as what it is. Any other one must stay on the public internet: a
34
+ * name that one day points into the local network is not followed there.
35
+ */
36
+ const isLocal = (url) => {
37
+ const host = url.hostname.replace(/^\[|\]$/g, "");
38
+ return isLocalName(host) || (net.isIP(host) !== 0 && isPrivateAddress(host));
39
+ };
40
+ /** Why a look failed, in words: what lies behind "fetch failed". */
41
+ const reason = (e, url) => {
42
+ const text = errText(e?.cause ?? e);
43
+ return /resolves to a private address/.test(text) ? `${url.hostname} leads to an address on your own network. If that is meant, set the source up again with "local": true` : text;
44
+ };
45
+ /** Headers that carry a secret. Those go into `token`, which is kept out of the configuration. */
46
+ const SECRET_HEADERS = ["authorization", "proxy-authorization", "cookie"];
47
+ export const type = {
48
+ summary: "a web address looked at again and again: reports when what it answers changes (the whole page, or one value in it: a price, a version, a state), and when it stops or starts answering. For pages and services that cannot call you",
49
+ options: Type.Object({
50
+ url: Type.String({ description: "the address to look at, https://... (http:// for a device on the local network)" }),
51
+ every: Type.Optional(Type.String({ description: 'how often to look, e.g. "5m", "1h". Default "5m", at least "30s"' })),
52
+ path: Type.Optional(Type.String({ description: 'for an answer in JSON: watch only this field, e.g. "data.price" or "items.0.state"' })),
53
+ match: Type.Optional(Type.String({ description: 'for a page or text: watch only what this regular expression finds (its first group, if it has one), e.g. "Price: ([0-9.,]+)". Without path or match the whole text counts, and a page that shows the time or a counter changes at every look' })),
54
+ headers: Type.Optional(Type.Record(Type.String(), Type.String(), { description: 'headers to send, e.g. {"accept": "application/json"}. Not for secrets' })),
55
+ token: Type.Optional(Type.String({ description: "an access token or key, if the address wants one. Sent as Authorization: Bearer <token>", secret: true })),
56
+ tokenHeader: Type.Optional(Type.String({ description: 'send the token as this header instead, as it is, e.g. "x-api-key"' })),
57
+ local: Type.Optional(Type.Boolean({ description: "true if the address is on your owner's own network under a name that does not say so (nas.home, ha.lan). Not needed for an IP address, localhost or a .local name" })),
58
+ }, { additionalProperties: false }),
59
+ plan(o) {
60
+ let url;
61
+ try {
62
+ url = new URL(o.url);
63
+ }
64
+ catch {
65
+ throw new Error(`"${o.url}" is not an address. It looks like https://example.com/page`);
66
+ }
67
+ if (!["http:", "https:"].includes(url.protocol))
68
+ throw new Error("the address must start with https:// or http://");
69
+ if (url.username || url.password)
70
+ throw new Error('an address with a user name or password in it is not taken: leave it out of the address and name "token" in "ask"');
71
+ const every = o.every ?? "5m";
72
+ if (parseDuration(every) < MIN)
73
+ throw new Error('"every" must be at least "30s"');
74
+ if (o.path && o.match)
75
+ throw new Error('"path" is for an answer in JSON, "match" for a page or text: give one of them');
76
+ if (o.match)
77
+ pattern(o.match);
78
+ const secret = Object.keys(o.headers ?? {}).filter((h) => SECRET_HEADERS.includes(h.toLowerCase()));
79
+ if (secret.length)
80
+ throw new Error(`the header ${secret.join(", ")} carries a secret: leave it out, name "token" in "ask" and give "tokenHeader": "${secret[0].toLowerCase()}"`);
81
+ if (o.tokenHeader && !/^[A-Za-z0-9-]+$/.test(o.tokenHeader))
82
+ throw new Error('"tokenHeader" is the name of a header, e.g. "x-api-key"');
83
+ const what = o.path ? `the field ${o.path} of what it answers` : o.match ? `what /${o.match}/ finds in it` : "what it answers";
84
+ return {
85
+ options: { ...o, url: url.href },
86
+ does: [`ask ${clip(url.href, 160)}${o.local || isLocal(url) ? " (an address on your own network or this computer)" : ""} every ${every} and report when ${what} changes, and when it stops or starts answering`, ...(o.token ? [`with the token given, sent only to ${url.host}`] : [])],
87
+ opens: true,
88
+ tell: "Its first look is the starting point and reports nothing; from then on only a change does. The sources tool shows what it found at the last look.",
89
+ };
90
+ },
91
+ };
92
+ /** One look: the answer's text, at most 1 MB of it. A redirect is followed only on the same host. */
93
+ async function get(start, o, guard, signal) {
94
+ const headers = { ...(o.headers ?? {}) };
95
+ if (o.token)
96
+ headers[o.tokenHeader ?? "authorization"] = o.tokenHeader ? o.token : `Bearer ${o.token}`;
97
+ let at = start;
98
+ for (let hop = 0; hop < 6; hop++) {
99
+ const res = await fetch(at, { headers, redirect: "manual", signal: AbortSignal.any([signal, AbortSignal.timeout(TIMEOUT)]), ...(guard ? { dispatcher: guard } : {}) });
100
+ const to = res.status >= 300 && res.status < 400 ? res.headers.get("location") : null;
101
+ if (to) {
102
+ await res.body?.cancel().catch(() => { });
103
+ const next = new URL(to, at);
104
+ // The owner confirmed one host. Another one is another decision, and must not get the token.
105
+ const upgrade = at.protocol === "http:" && next.protocol === "https:";
106
+ if (next.hostname !== start.hostname || (next.protocol !== at.protocol && !upgrade) || (next.port !== at.port && !upgrade))
107
+ throw new Error(`it sends on to ${next.host}, which is not the address that was confirmed. If that is the right one, set the source up with it`);
108
+ at = next;
109
+ continue;
110
+ }
111
+ if (!res.ok) {
112
+ await res.body?.cancel().catch(() => { });
113
+ throw new Error(`it answered ${res.status}`);
114
+ }
115
+ const chunks = [];
116
+ let size = 0;
117
+ const reader = res.body?.getReader();
118
+ while (reader) {
119
+ const { done, value } = await reader.read();
120
+ if (done)
121
+ break;
122
+ chunks.push(Buffer.from(value.subarray(0, MAX_BODY - size)));
123
+ size += value.length;
124
+ if (size >= MAX_BODY) {
125
+ await reader.cancel().catch(() => { });
126
+ break;
127
+ }
128
+ }
129
+ return { type: res.headers.get("content-type") ?? "", body: Buffer.concat(chunks).toString("utf8") };
130
+ }
131
+ throw new Error("it sends on and on (more than five redirects)");
132
+ }
133
+ export default function http(source) {
134
+ const o = source.options;
135
+ if (typeof o.url !== "string")
136
+ throw new Error(`needs "url": the address to look at`);
137
+ const url = new URL(o.url);
138
+ if (!["http:", "https:"].includes(url.protocol))
139
+ throw new Error("the address must start with https:// or http://");
140
+ const re = o.match ? pattern(String(o.match)) : undefined;
141
+ source.secret(o.token);
142
+ const guard = o.local === true || isLocal(url) ? undefined : publicOnlyAgent(TIMEOUT);
143
+ if (guard)
144
+ source.onStop(() => guard.close());
145
+ // How many looks in a row failed. Whether "down" was said is kept, so that "up" follows it also across a restart.
146
+ let failed = 0;
147
+ source.poll(o.every ?? "5m", async (signal) => {
148
+ let answer;
149
+ try {
150
+ answer = await get(url, o, guard, signal);
151
+ }
152
+ catch (e) {
153
+ if (signal.aborted)
154
+ return;
155
+ const why = reason(e, url);
156
+ failed++;
157
+ if (failed === DOWN_AFTER && !source.kv.get("down")) {
158
+ source.kv.set("down", true);
159
+ source.emit("down", { url: o.url, error: why, looks: failed });
160
+ }
161
+ throw new Error(why);
162
+ }
163
+ failed = 0;
164
+ if (source.kv.get("down")) {
165
+ source.kv.set("down", false);
166
+ source.emit("up", { url: o.url });
167
+ }
168
+ const html = /html/i.test(answer.type) || /^\s*<(!doctype html|html)\b/i.test(answer.body);
169
+ let json;
170
+ if (!html) {
171
+ try {
172
+ json = JSON.parse(answer.body);
173
+ }
174
+ catch {
175
+ // plain text
176
+ }
177
+ }
178
+ if (o.path || re) {
179
+ let value = null;
180
+ if (o.path)
181
+ value = json === undefined ? null : (getPath(json, String(o.path)) ?? null);
182
+ // In what a reader sees first; a pattern that is about the markup finds it in the source.
183
+ else
184
+ value = (html ? found(re, pageText(answer.body)) : null) ?? found(re, answer.body);
185
+ source.status(value === null ? `found nothing ${o.path ? `at ${o.path}` : "with the pattern"}` : `now: ${harmless(value)}`);
186
+ const was = source.changed(value);
187
+ if (was)
188
+ source.emit("changed", { url: o.url, value: shown(value), previous: shown(was.previous) });
189
+ return;
190
+ }
191
+ const text = (html ? pageText(answer.body) : json !== undefined ? JSON.stringify(json, null, 1) : answer.body).slice(0, 200_000);
192
+ source.status(`now: ${text.split("\n").length} lines`);
193
+ const was = source.changed(text);
194
+ if (!was)
195
+ return;
196
+ const diff = lineDiff(String(was.previous), text);
197
+ // The same lines in another order are not worth a turn.
198
+ if (diff.added.length || diff.removed.length)
199
+ source.emit("changed", { url: o.url, ...diff });
200
+ });
201
+ }
@@ -0,0 +1,18 @@
1
+ import type { SourceType } from "../source-types.ts";
2
+ import type { SourceApi } from "../sources.ts";
3
+ /**
4
+ * An MQTT broker: reports the messages on the topics you name. For home automation hubs, sensors,
5
+ * and devices that speak MQTT themselves (a Bambu printer does, on mqtts://<printer>:8883 with the
6
+ * user "bblp", its access code as password, the topic "device/<serial>/report" and insecureTls).
7
+ *
8
+ * options: { url, topics, publish?, username?, password?, clientId?, minInterval?, insecureTls? }
9
+ * topics one MQTT topic filter or a list ("zigbee2mqtt/+", "device/+/report")
10
+ * publish topics an agent may send to, to act on a device ("zigbee2mqtt/+/set"). Without it nothing can be sent
11
+ * minInterval drop messages that follow the last one on the same topic sooner than this ("30s")
12
+ * insecureTls accept a certificate that cannot be verified, as devices with self-made ones need
13
+ *
14
+ * event "message": { topic, value } value is parsed JSON when the message is JSON, else text
15
+ * It can send, to the topics on `publish` only: { topic, message, retain?, qos? }
16
+ */
17
+ export declare const type: SourceType;
18
+ export default function mqttSource(source: SourceApi): Promise<void>;
@@ -0,0 +1,97 @@
1
+ import { Type } from "typebox";
2
+ import { errText, matchesAny, parseDuration } from "../util.js";
3
+ /**
4
+ * An MQTT broker: reports the messages on the topics you name. For home automation hubs, sensors,
5
+ * and devices that speak MQTT themselves (a Bambu printer does, on mqtts://<printer>:8883 with the
6
+ * user "bblp", its access code as password, the topic "device/<serial>/report" and insecureTls).
7
+ *
8
+ * options: { url, topics, publish?, username?, password?, clientId?, minInterval?, insecureTls? }
9
+ * topics one MQTT topic filter or a list ("zigbee2mqtt/+", "device/+/report")
10
+ * publish topics an agent may send to, to act on a device ("zigbee2mqtt/+/set"). Without it nothing can be sent
11
+ * minInterval drop messages that follow the last one on the same topic sooner than this ("30s")
12
+ * insecureTls accept a certificate that cannot be verified, as devices with self-made ones need
13
+ *
14
+ * event "message": { topic, value } value is parsed JSON when the message is JSON, else text
15
+ * It can send, to the topics on `publish` only: { topic, message, retain?, qos? }
16
+ */
17
+ export const type = {
18
+ summary: "an MQTT broker: reports the messages on the topics you name. For home automation hubs, sensors, and devices that speak MQTT themselves (printers, plugs). With \"publish\" it can also send, to act on a device",
19
+ sends: 'an MQTT message to a topic on its "publish" list, to act on a device: {"topic": "lamp/set", "message": "on"} (message: text or JSON)',
20
+ options: Type.Object({
21
+ url: Type.String({ description: "address of the broker: mqtt://host:1883, or mqtts://host:8883 for TLS" }),
22
+ topics: Type.Union([Type.String(), Type.Array(Type.String())], { description: 'the topic filters to listen to, e.g. "zigbee2mqtt/+" or ["device/+/report"]' }),
23
+ publish: Type.Optional(Type.Array(Type.String(), { description: 'topics you may send to, to act on a device, e.g. ["zigbee2mqtt/+/set"]. Only when your owner wants you to control something; without it nothing can be sent' })),
24
+ username: Type.Optional(Type.String({ description: "user name, if the broker wants one" })),
25
+ password: Type.Optional(Type.String({ description: "password, access code or token, if the broker wants one", secret: true })),
26
+ clientId: Type.Optional(Type.String({ description: "client id, for brokers that want a particular one" })),
27
+ minInterval: Type.Optional(Type.String({ description: 'drop messages that follow the last one on the same topic sooner than this, e.g. "30s". For devices that report many times a second' })),
28
+ insecureTls: Type.Optional(Type.Boolean({ description: "accept a certificate that cannot be verified, as devices with self-made ones need" })),
29
+ }, { additionalProperties: false }),
30
+ plan(o) {
31
+ let url;
32
+ try {
33
+ url = new URL(o.url);
34
+ }
35
+ catch {
36
+ throw new Error(`"${o.url}" is not an address. An MQTT broker's looks like mqtt://192.168.1.10:1883`);
37
+ }
38
+ if (!["mqtt:", "mqtts:", "ws:", "wss:"].includes(url.protocol))
39
+ throw new Error("an MQTT broker's address starts with mqtt://, mqtts://, ws:// or wss://");
40
+ const topics = (Array.isArray(o.topics) ? o.topics : String(o.topics).split(",")).map((t) => t.trim()).filter(Boolean);
41
+ if (!topics.length)
42
+ throw new Error(`needs "topics": which MQTT topics to listen to`);
43
+ if (o.minInterval)
44
+ parseDuration(o.minInterval);
45
+ const publish = (Array.isArray(o.publish) ? o.publish : []).map((t) => String(t).trim()).filter(Boolean);
46
+ return { options: { ...o, topics, ...(publish.length ? { publish } : {}) }, does: [`connect to the MQTT broker ${url.host}${o.username ? ` as ${o.username}` : ""} and listen to ${topics.join(", ")}`, ...(publish.length ? [`let the agent send messages to ${publish.join(", ")}, which can switch and control devices`] : []), ...(o.password ? ["with the password given"] : []), ...(o.insecureTls ? ["without checking its certificate"] : [])], opens: true };
47
+ },
48
+ };
49
+ export default async function mqttSource(source) {
50
+ const { default: mqtt } = await import("mqtt");
51
+ const o = source.options;
52
+ if (typeof o.url !== "string")
53
+ throw new Error(`needs "url", for example mqtt://192.168.1.10:1883`);
54
+ const topics = (Array.isArray(o.topics) ? o.topics : o.topics ? [o.topics] : []).map(String);
55
+ if (!topics.length)
56
+ throw new Error(`needs "topics": which MQTT topics to listen to`);
57
+ const minMs = o.minInterval !== undefined ? parseDuration(o.minInterval) : 0;
58
+ source.secret(o.password);
59
+ const client = mqtt.connect(o.url, { username: o.username, password: o.password, clientId: o.clientId ?? `pine-${source.name}-${process.pid}`, reconnectPeriod: 5000, connectTimeout: 10_000, rejectUnauthorized: o.insecureTls !== true });
60
+ source.onStop(() => new Promise((r) => client.end(true, {}, () => r())));
61
+ client.on("error", (e) => source.log.warn("mqtt error", errText(e)));
62
+ client.on("connect", () => {
63
+ source.log.info(`connected to ${new URL(o.url).host}`);
64
+ for (const t of topics)
65
+ client.subscribe(t, (err) => err && source.log.warn(`subscribe ${t} failed`, errText(err)));
66
+ });
67
+ const last = new Map();
68
+ client.on("message", (topic, buf) => {
69
+ const now = Date.now();
70
+ if (minMs && now - (last.get(topic) ?? 0) < minMs)
71
+ return;
72
+ last.set(topic, now);
73
+ if (last.size > 5000)
74
+ last.clear();
75
+ const text = buf.length > 256 * 1024 ? "" : buf.toString("utf8");
76
+ let value = text;
77
+ try {
78
+ value = JSON.parse(text);
79
+ }
80
+ catch {
81
+ // plain text
82
+ }
83
+ source.emit("message", { topic, value });
84
+ });
85
+ // Sending: only to the topics the owner put on the list, and never to a filter.
86
+ const allowed = (Array.isArray(o.publish) ? o.publish : []).map(String);
87
+ source.onSend(async (m) => {
88
+ const topic = String(m.topic ?? "");
89
+ if (!topic)
90
+ throw new Error('say which topic: {"topic": "...", "message": "..."}');
91
+ if (/[+#]/.test(topic) || !matchesAny(allowed, topic))
92
+ throw new Error(`"${topic}" is not a topic this source may send to (${allowed.join(", ") || "none: it was set up to listen only"})`);
93
+ const message = m.message ?? m.text ?? "";
94
+ await new Promise((resolve, reject) => client.publish(topic, typeof message === "string" ? message : JSON.stringify(message), { retain: m.retain === true, qos: [0, 1, 2].includes(m.qos) ? m.qos : 0 }, (err) => (err ? reject(err) : resolve())));
95
+ return `published to ${topic}`;
96
+ });
97
+ }
@@ -0,0 +1,22 @@
1
+ import type { SourceType } from "../source-types.ts";
2
+ import type { SourceApi } from "../sources.ts";
3
+ /**
4
+ * A Telegram bot: the owner writes to it from wherever they are, and the agent answers there.
5
+ *
6
+ * options: { token, allow?, approvals?, apiBase? }
7
+ * token the bot's token from @BotFather
8
+ * allow Telegram user ids that may talk to it. Without it the bot is paired: it sends a
9
+ * code to the account that writes to it, and the owner types that code into pine
10
+ * approvals also send approval requests to that chat, with buttons (default true)
11
+ * apiBase a Bot API server of your own instead of api.telegram.org
12
+ *
13
+ * event "message": { text, name, username, chatId, messageId, images? }
14
+ * It can send: { text, chatId? } goes to the owner's chat.
15
+ *
16
+ * Messages are fetched by long polling: one request that Telegram answers the moment something
17
+ * arrives. Nothing listens on a port, so it works behind any router. Only accounts on `allow`, or
18
+ * the one that was paired, are heard. What anyone else writes reaches no agent; once an account
19
+ * is paired they get no answer at all, and nothing is ever sent to them.
20
+ */
21
+ export declare const type: SourceType;
22
+ export default function telegram(source: SourceApi): Promise<void>;