@astrofoundry/pi-astro 0.29.1 → 0.30.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/extensions/astro-discord/admin.ts +1 -0
- package/extensions/astro-discord/config.ts +24 -0
- package/extensions/astro-discord/daily-report.test.ts +109 -0
- package/extensions/astro-discord/daily-report.ts +122 -0
- package/extensions/astro-discord/index.ts +61 -4
- package/package.json +1 -1
- package/skills/arcane/SKILL.md +2 -0
- package/specialists/README.md +1 -1
|
@@ -32,6 +32,7 @@ export function describeConfig(config: DiscordConfig): string {
|
|
|
32
32
|
access.length > 0 ? "Extra access:" : "Extra access: none",
|
|
33
33
|
...access,
|
|
34
34
|
`Approval timeout: ${config.approvalTimeoutMinutes} min`,
|
|
35
|
+
config.dailyReport ? `Arcane daily report: ${config.dailyReport.time} ${config.dailyReport.timeZone} in <#${config.dailyReport.channelId}>` : "Arcane daily report: off",
|
|
35
36
|
].join("\n");
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -26,6 +26,8 @@ export interface DiscordConfig {
|
|
|
26
26
|
/** Extra users per specialist name (without the astro. prefix). */
|
|
27
27
|
access: Record<string, string[]>;
|
|
28
28
|
approvalTimeoutMinutes: number;
|
|
29
|
+
/** Optional scheduled Arcane version report. */
|
|
30
|
+
dailyReport?: { channelId: string; time: string; timeZone: string };
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
export const SNOWFLAKE = /^\d{17,20}$/;
|
|
@@ -90,6 +92,7 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
|
|
|
90
92
|
}
|
|
91
93
|
const timeout = r.approvalTimeoutMinutes;
|
|
92
94
|
if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < 1 || timeout > 240) errors.push("approvalTimeoutMinutes must be an integer from 1 to 240");
|
|
95
|
+
const dailyReport = r.dailyReport === undefined ? undefined : validateDailyReport(r.dailyReport, errors);
|
|
93
96
|
if (errors.length > 0) return null;
|
|
94
97
|
return {
|
|
95
98
|
applicationId: r.applicationId as string,
|
|
@@ -99,9 +102,30 @@ export function validateConfig(raw: unknown, errors: string[]): DiscordConfig |
|
|
|
99
102
|
owners,
|
|
100
103
|
access,
|
|
101
104
|
approvalTimeoutMinutes: timeout as number,
|
|
105
|
+
...(dailyReport ? { dailyReport } : {}),
|
|
102
106
|
};
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
function validateDailyReport(raw: unknown, errors: string[]): DiscordConfig["dailyReport"] | undefined {
|
|
110
|
+
const r = raw as Record<string, unknown> | null;
|
|
111
|
+
if (typeof r !== "object" || r === null) {
|
|
112
|
+
errors.push("dailyReport must be an object");
|
|
113
|
+
return undefined;
|
|
114
|
+
}
|
|
115
|
+
if (typeof r.channelId !== "string" || !SNOWFLAKE.test(r.channelId)) errors.push("dailyReport.channelId must be a Discord id");
|
|
116
|
+
if (typeof r.time !== "string" || !/^([01]\d|2[0-3]):[0-5]\d$/.test(r.time)) errors.push("dailyReport.time must be HH:MM");
|
|
117
|
+
let zoneOk = typeof r.timeZone === "string";
|
|
118
|
+
if (zoneOk) {
|
|
119
|
+
try {
|
|
120
|
+
new Intl.DateTimeFormat("en", { timeZone: r.timeZone as string });
|
|
121
|
+
} catch {
|
|
122
|
+
zoneOk = false;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (!zoneOk) errors.push("dailyReport.timeZone must be an IANA time zone such as Europe/Madrid");
|
|
126
|
+
return errors.length > 0 ? undefined : { channelId: r.channelId as string, time: r.time as string, timeZone: r.timeZone as string };
|
|
127
|
+
}
|
|
128
|
+
|
|
105
129
|
export function validateChannel(raw: unknown, where: string, errors: string[]): ChannelSettings | null {
|
|
106
130
|
if (typeof raw !== "object" || raw === null) {
|
|
107
131
|
errors.push(`${where} must be an object`);
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { validateConfig } from "./config.ts";
|
|
6
|
+
import { displayVersion, failureMessage, formatDailyReport, isDue, readReportState, REPORT_PREFIX, writeReportState, zonedNow } from "./daily-report.ts";
|
|
7
|
+
|
|
8
|
+
const settings = { channelId: "1549873938140499999", time: "06:00", timeZone: "Europe/Madrid" };
|
|
9
|
+
|
|
10
|
+
describe("daily report schedule", () => {
|
|
11
|
+
it("follows Madrid's daylight-saving offsets", () => {
|
|
12
|
+
expect(zonedNow("Europe/Madrid", new Date("2026-01-15T05:00:00Z"))).toEqual({ date: "2026-01-15", minutes: 360 });
|
|
13
|
+
expect(zonedNow("Europe/Madrid", new Date("2026-07-15T04:00:00Z"))).toEqual({ date: "2026-07-15", minutes: 360 });
|
|
14
|
+
expect(zonedNow("Europe/Madrid", new Date("2026-10-25T05:00:00Z"))).toEqual({ date: "2026-10-25", minutes: 360 });
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("is due once per local date, from the configured time on", () => {
|
|
18
|
+
const before = new Date("2026-07-15T03:59:00Z");
|
|
19
|
+
const at = new Date("2026-07-15T04:00:00Z");
|
|
20
|
+
const later = new Date("2026-07-15T09:30:00Z");
|
|
21
|
+
expect(isDue(settings, null, before)).toEqual({ due: false, date: "2026-07-15" });
|
|
22
|
+
expect(isDue(settings, null, at)).toEqual({ due: true, date: "2026-07-15" });
|
|
23
|
+
expect(isDue(settings, null, later).due).toBe(true);
|
|
24
|
+
expect(isDue(settings, { date: "2026-07-15", status: "sending" }, later).due).toBe(false);
|
|
25
|
+
expect(isDue(settings, { date: "2026-07-14", status: "sent" }, later).due).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("keeps its state across processes", () => {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), "astro-report-"));
|
|
30
|
+
process.env.ASTRO_DISCORD_DIR = dir;
|
|
31
|
+
try {
|
|
32
|
+
expect(readReportState()).toBeNull();
|
|
33
|
+
writeReportState({ date: "2026-10-19", status: "sent", messageId: "1" });
|
|
34
|
+
expect(readReportState()).toEqual({ date: "2026-10-19", status: "sent", messageId: "1" });
|
|
35
|
+
} finally {
|
|
36
|
+
delete process.env.ASTRO_DISCORD_DIR;
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("daily report message", () => {
|
|
43
|
+
let base: Record<string, unknown>;
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
base = { checkedAt: "2026-10-19T04:00:05Z", stacks: 19, images: 44, privateImages: ["a", "b"], counts: {}, rows: [] };
|
|
46
|
+
});
|
|
47
|
+
afterEach(() => undefined);
|
|
48
|
+
|
|
49
|
+
it("lists each service as current to target, unresolved where no release tag matches", () => {
|
|
50
|
+
const report = {
|
|
51
|
+
...base,
|
|
52
|
+
rows: [
|
|
53
|
+
{ stack: "crawl4ai", service: "crawl4ai", running: "0.9.3", latest: "0.9.4", status: "update", action: "pull", note: "" },
|
|
54
|
+
{ stack: "media", service: "sonarr", running: "4.0.20.3014-ls325", latest: "4.0.20.3014-ls326", status: "update", action: "pull", note: "" },
|
|
55
|
+
{ stack: "qdrant", service: "volume-init", running: "older build", latest: "1.38.0", status: "update", action: "pull", note: "" },
|
|
56
|
+
{ stack: "profiles", service: "profiles", running: "1.31.4-alpine", latest: "1.31.6-alpine", status: "release", action: "edit tag", note: "" },
|
|
57
|
+
{ stack: "arcane", service: "arcane", running: "v2.16.1", latest: "v2.17.0", status: "update", action: "edit digest", note: "" },
|
|
58
|
+
{ stack: "stirling-pdf", service: "stirling-pdf", running: "2.14.3", latest: "3.0.0", status: "update", action: "pull", note: "major version change: read its release notes before pulling" },
|
|
59
|
+
{ stack: "bigcapital", service: "gotenberg", running: "7.10.2", latest: "7.10.2", status: "up-to-date", action: "", note: "newer major 8 available (tag change)" },
|
|
60
|
+
{ stack: "x", service: "y", running: "?", latest: "?", status: "unknown", action: "", note: "stale" },
|
|
61
|
+
],
|
|
62
|
+
};
|
|
63
|
+
const { content, attachment } = formatDailyReport(report, "Europe/Madrid");
|
|
64
|
+
expect(attachment).toBeUndefined();
|
|
65
|
+
expect(content.split("\n")).toEqual([
|
|
66
|
+
`${REPORT_PREFIX} 6 updates (44 public images, 2 private excluded, checked 06:00 CEST).`,
|
|
67
|
+
"• crawl4ai: 0.9.3 → 0.9.4",
|
|
68
|
+
"• media/sonarr: 4.0.20.3014-ls325 → 4.0.20.3014-ls326",
|
|
69
|
+
"• qdrant/volume-init: unresolved → 1.38.0",
|
|
70
|
+
"• profiles: 1.31.4-alpine → 1.31.6-alpine (tag change)",
|
|
71
|
+
"• arcane: v2.16.1 → v2.17.0 (digest change)",
|
|
72
|
+
"• stirling-pdf: 2.14.3 → 3.0.0; major version change",
|
|
73
|
+
"• bigcapital/gotenberg: newer major 8 available (tag change)",
|
|
74
|
+
"• x/y: unresolved (check failed)",
|
|
75
|
+
"Checks only; nothing was updated.",
|
|
76
|
+
]);
|
|
77
|
+
expect(content).not.toMatch(/sha256/);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("says so when nothing needs updating, and moves a long list to an attachment", () => {
|
|
81
|
+
expect(formatDailyReport(base, "Europe/Madrid").content).toBe(`${REPORT_PREFIX} no updates (44 public images, 2 private excluded, checked 06:00 CEST).\nChecks only; nothing was updated.`);
|
|
82
|
+
const rows = Array.from({ length: 60 }, (_, i) => ({ stack: `stack-${i}`, service: `service-${i}`, running: "1.0.0", latest: "1.0.1", status: "update", action: "pull", note: "" }));
|
|
83
|
+
const long = formatDailyReport({ ...base, rows }, "Europe/Madrid");
|
|
84
|
+
expect(long.content).toContain("The full list is attached.");
|
|
85
|
+
expect(long.attachment?.split("\n")).toHaveLength(61);
|
|
86
|
+
expect(formatDailyReport({ nonsense: true }, "Europe/Madrid").content).toBe(failureMessage());
|
|
87
|
+
expect(failureMessage().startsWith(REPORT_PREFIX)).toBe(true);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("never prints anything that is not a release tag", () => {
|
|
91
|
+
expect(displayVersion("v1.2.3")).toBe("v1.2.3");
|
|
92
|
+
expect(displayVersion("17 (older build)")).toBe("unresolved");
|
|
93
|
+
expect(displayVersion("sha256:abcdef")).toBe("unresolved");
|
|
94
|
+
expect(displayVersion(undefined)).toBe("unresolved");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("daily report config", () => {
|
|
99
|
+
const valid = { applicationId: "123456789012345678", guildId: "123456789012345678", channels: { "123456789012345678": { trigger: "always", specialists: "*" } }, adminChannelId: "123456789012345678", owners: ["123456789012345678"], access: {}, approvalTimeoutMinutes: 30 };
|
|
100
|
+
it("accepts a valid schedule and rejects a bad zone or time", () => {
|
|
101
|
+
const errors: string[] = [];
|
|
102
|
+
expect(validateConfig({ ...valid, dailyReport: settings }, errors)?.dailyReport).toEqual(settings);
|
|
103
|
+
expect(validateConfig({ ...valid, dailyReport: { ...settings, timeZone: "Mars/Olympus" } }, errors)).toBeNull();
|
|
104
|
+
expect(errors.join(" ")).toMatch(/IANA/);
|
|
105
|
+
const more: string[] = [];
|
|
106
|
+
expect(validateConfig({ ...valid, dailyReport: { ...settings, time: "6:00" } }, more)).toBeNull();
|
|
107
|
+
expect(validateConfig(valid, [])?.dailyReport).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { configDir } from "./config.ts";
|
|
4
|
+
|
|
5
|
+
/** Every scheduled report starts with this, so it stands apart from conversations in the same channel. */
|
|
6
|
+
export const REPORT_PREFIX = "Arcane Daily Report:";
|
|
7
|
+
const MESSAGE_LIMIT = 1900;
|
|
8
|
+
|
|
9
|
+
/** `dailyReport` in config.json: where and when the bridge posts the Arcane version report. */
|
|
10
|
+
export interface DailyReportSettings {
|
|
11
|
+
channelId: string;
|
|
12
|
+
/** Local wall-clock time, `HH:MM`, in `timeZone`. */
|
|
13
|
+
time: string;
|
|
14
|
+
/** IANA zone such as `Europe/Madrid`; daylight-saving changes follow from it. */
|
|
15
|
+
timeZone: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DailyReportState {
|
|
19
|
+
/** Local date in the report's time zone, `YYYY-MM-DD`. */
|
|
20
|
+
date: string;
|
|
21
|
+
status: "sending" | "sent" | "failed";
|
|
22
|
+
messageId?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Local date and minutes past midnight in a time zone. */
|
|
26
|
+
export function zonedNow(timeZone: string, now: Date): { date: string; minutes: number } {
|
|
27
|
+
const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" }).formatToParts(now);
|
|
28
|
+
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? "";
|
|
29
|
+
return { date: `${get("year")}-${get("month")}-${get("day")}`, minutes: Number(get("hour")) * 60 + Number(get("minute")) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Due once per local date, from the configured time on: a bridge that was down
|
|
34
|
+
* at 06:00 reports when it comes back that day. Any record for today, even a
|
|
35
|
+
* failed or interrupted send, means no second message.
|
|
36
|
+
*/
|
|
37
|
+
export function isDue(settings: DailyReportSettings, state: DailyReportState | null, now: Date): { due: boolean; date: string } {
|
|
38
|
+
const { date, minutes } = zonedNow(settings.timeZone, now);
|
|
39
|
+
const [h, m] = settings.time.split(":").map(Number);
|
|
40
|
+
return { due: minutes >= h * 60 + m && state?.date !== date, date };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function statePath(): string {
|
|
44
|
+
return join(configDir(), "daily-report.json");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function readReportState(): DailyReportState | null {
|
|
48
|
+
try {
|
|
49
|
+
const raw = JSON.parse(readFileSync(statePath(), "utf-8")) as Partial<DailyReportState>;
|
|
50
|
+
return typeof raw.date === "string" && typeof raw.status === "string" ? (raw as DailyReportState) : null;
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function writeReportState(state: DailyReportState): void {
|
|
57
|
+
const tmp = `${statePath()}.tmp`;
|
|
58
|
+
writeFileSync(tmp, `${JSON.stringify(state)}\n`, { mode: 0o600 });
|
|
59
|
+
renameSync(tmp, statePath());
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A release tag as reported, or "unresolved" for anything that is not one (`older build`, `new build`, `?`). Never a digest. */
|
|
63
|
+
export function displayVersion(value: unknown): string {
|
|
64
|
+
return typeof value === "string" && /^v?[0-9][A-Za-z0-9.+_-]{0,100}$/.test(value) ? value : "unresolved";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface ReportRow {
|
|
68
|
+
stack: string;
|
|
69
|
+
service: string;
|
|
70
|
+
running: unknown;
|
|
71
|
+
latest: unknown;
|
|
72
|
+
status: string;
|
|
73
|
+
action?: string;
|
|
74
|
+
note?: string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface UpdateReport {
|
|
78
|
+
checkedAt: string;
|
|
79
|
+
images: number;
|
|
80
|
+
privateImages: unknown[];
|
|
81
|
+
rows: ReportRow[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isReport(value: unknown): value is UpdateReport {
|
|
85
|
+
const r = value as Partial<UpdateReport> | null;
|
|
86
|
+
return !!r && typeof r.checkedAt === "string" && typeof r.images === "number" && Array.isArray(r.privateImages) && Array.isArray(r.rows);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function clock(iso: string, timeZone: string): string {
|
|
90
|
+
return new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" }).format(new Date(iso));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function name(row: ReportRow): string {
|
|
94
|
+
return row.service === row.stack ? row.stack : `${row.stack}/${row.service}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** One line per service: current to target version, with how the update applies when it is not a plain pull. */
|
|
98
|
+
function line(row: ReportRow): string | null {
|
|
99
|
+
const major = /newer major (\S+) available/.exec(row.note ?? "");
|
|
100
|
+
if (row.status === "up-to-date") return major ? `• ${name(row)}: newer major ${major[1]} available (tag change)` : null;
|
|
101
|
+
if (row.status === "unknown") return `• ${name(row)}: unresolved (check failed)`;
|
|
102
|
+
const how = row.status === "rebuild" ? " (rebuild)" : row.action === "edit tag" ? " (tag change)" : row.action === "edit digest" ? " (digest change)" : "";
|
|
103
|
+
const extra = major ? `; newer major ${major[1]} available` : /major version change/.test(row.note ?? "") ? "; major version change" : "";
|
|
104
|
+
return `• ${name(row)}: ${displayVersion(row.running)} → ${displayVersion(row.latest)}${how}${extra}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The Discord message for one `update-report` result; a list too long for one message moves to an attachment. */
|
|
108
|
+
export function formatDailyReport(report: unknown, timeZone: string): { content: string; attachment?: string } {
|
|
109
|
+
if (!isReport(report)) return { content: failureMessage() };
|
|
110
|
+
const lines = report.rows.map(line).filter((l): l is string => l !== null);
|
|
111
|
+
const updates = report.rows.filter((r) => r.status === "update" || r.status === "release" || r.status === "rebuild").length;
|
|
112
|
+
const scope = `${report.images} public images${report.privateImages.length > 0 ? `, ${report.privateImages.length} private excluded` : ""}, checked ${clock(report.checkedAt, timeZone)}`;
|
|
113
|
+
const head = updates === 0 && lines.length === 0 ? `${REPORT_PREFIX} no updates (${scope}).` : `${REPORT_PREFIX} ${updates} update${updates === 1 ? "" : "s"} (${scope}).`;
|
|
114
|
+
const body = [head, ...lines, "Checks only; nothing was updated."].join("\n");
|
|
115
|
+
if (body.length <= MESSAGE_LIMIT) return { content: body };
|
|
116
|
+
return { content: `${head}\nThe full list is attached.\nChecks only; nothing was updated.`, attachment: [head, ...lines].join("\n") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Sent when the check itself fails: no list, so nothing reads as up to date. */
|
|
120
|
+
export function failureMessage(): string {
|
|
121
|
+
return `${REPORT_PREFIX} the version check failed today, so there is no update list. The bridge log on Cortex has the reason.`;
|
|
122
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
1
2
|
import { promises as fs } from "node:fs";
|
|
2
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import { loadConfig as loadSpecialists } from "../specialist-gate/config.ts";
|
|
@@ -12,6 +13,7 @@ import { CHANNEL_RULES, helpText, needsPrefixText, parseCommand } from "./comman
|
|
|
12
13
|
import { ACTIVATION_ENV, type ChannelSettings, type DiscordConfig, allowedSpecialists, canUse, channelSpecialists, isOwner, loadConfig, loadToken, saveConfig } from "./config.ts";
|
|
13
14
|
import { type ConversationState, type ResolvedConversation, forgetStale, loadState, newConversationId, recordAnswer, resolveConversation, saveState, sessionsRoot, startConversation } from "./conversations.ts";
|
|
14
15
|
import { describeImages, downloadImages, type ImageAttachment, imageAttachments } from "./attachments.ts";
|
|
16
|
+
import { failureMessage, formatDailyReport, isDue, readReportState, writeReportState } from "./daily-report.ts";
|
|
15
17
|
import { GatewayClient } from "./gateway.ts";
|
|
16
18
|
import { cancelledApprovalText, interruptedTaskText, readOpenWork, type OpenWork, type StopReason, writeOpenWork } from "./open-work.ts";
|
|
17
19
|
import { type Quote, type QuotedMessage, withQuotes } from "./quote.ts";
|
|
@@ -123,6 +125,8 @@ class Bridge {
|
|
|
123
125
|
private readonly approvalMessages = new Map<string, { channelId: string; messageId: string; userMessage: ActiveTask }>();
|
|
124
126
|
private gateway: GatewayClient | undefined;
|
|
125
127
|
private stopping = false;
|
|
128
|
+
private reportRunning = false;
|
|
129
|
+
private reportTimer: NodeJS.Timeout | undefined;
|
|
126
130
|
private readonly since = new Date().toISOString();
|
|
127
131
|
private config: DiscordConfig;
|
|
128
132
|
private conversations: ConversationState;
|
|
@@ -196,6 +200,7 @@ class Bridge {
|
|
|
196
200
|
async shutdown(): Promise<void> {
|
|
197
201
|
if (this.stopping) return;
|
|
198
202
|
this.stopping = true;
|
|
203
|
+
clearInterval(this.reportTimer);
|
|
199
204
|
this.gateway?.stop();
|
|
200
205
|
const work = this.openWork();
|
|
201
206
|
for (const task of this.active.values()) task.abort?.abort();
|
|
@@ -225,6 +230,10 @@ class Bridge {
|
|
|
225
230
|
});
|
|
226
231
|
gateway.start();
|
|
227
232
|
this.gateway = gateway;
|
|
233
|
+
// Checked every minute; the report itself decides whether today's is due.
|
|
234
|
+
this.reportTimer = setInterval(() => void this.maybeDailyReport(), 60_000);
|
|
235
|
+
this.reportTimer.unref();
|
|
236
|
+
void this.maybeDailyReport();
|
|
228
237
|
log(`connected as ${me.username} (${me.id}); channels ${Object.keys(this.config.channels).join(", ")}; specialists ${this.specialists().names.join(", ")}`);
|
|
229
238
|
}
|
|
230
239
|
|
|
@@ -308,7 +317,8 @@ class Bridge {
|
|
|
308
317
|
);
|
|
309
318
|
if (!conversation.resumed) this.conversations = startConversation(this.conversations, conversation.key, conversation.id, now);
|
|
310
319
|
saveState(this.conversations);
|
|
311
|
-
const
|
|
320
|
+
const known = conversation.resumed ? (this.conversations.conversations[conversation.key]?.messageIds ?? []) : [];
|
|
321
|
+
const quotes = this.quotesFor(message, (id) => known.includes(id));
|
|
312
322
|
const images = imageAttachments([message, ...quotes.map((q) => q.message)]);
|
|
313
323
|
const task: ActiveTask = { specialist: command.specialist, channelId: message.channel_id, messageId: message.id, userId, startedAt: now, conversation, images };
|
|
314
324
|
const taskText = withQuotes(command.task, quotes);
|
|
@@ -317,14 +327,61 @@ class Bridge {
|
|
|
317
327
|
this.queues.set(command.specialist, next);
|
|
318
328
|
}
|
|
319
329
|
|
|
320
|
-
/**
|
|
321
|
-
|
|
330
|
+
/**
|
|
331
|
+
* Runs the Arcane version report through the specialist wrapper, without a
|
|
332
|
+
* model, and posts it with the prefix. One message per local date: the date
|
|
333
|
+
* is recorded before the post, so a crash during delivery never repeats it.
|
|
334
|
+
*/
|
|
335
|
+
private async maybeDailyReport(): Promise<void> {
|
|
336
|
+
if (this.reportRunning || this.stopping) return;
|
|
337
|
+
this.refreshConfig();
|
|
338
|
+
const settings = this.config.dailyReport;
|
|
339
|
+
if (!settings) return;
|
|
340
|
+
const { due, date } = isDue(settings, readReportState(), new Date());
|
|
341
|
+
if (!due) return;
|
|
342
|
+
const gate = loadSpecialists().config;
|
|
343
|
+
if (!gate) {
|
|
344
|
+
log("daily report: specialists.json missing; no report");
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
this.reportRunning = true;
|
|
348
|
+
try {
|
|
349
|
+
log(`daily report ${date}: running arcane update-report`);
|
|
350
|
+
let message: { content: string; attachment?: string };
|
|
351
|
+
try {
|
|
352
|
+
const stdout = await new Promise<string>((resolve, reject) =>
|
|
353
|
+
execFile("/usr/bin/sudo", ["-n", "-u", gate.user, "-H", gate.entry, "arcane", "--caller", "daily-report", "--", "update-report"], { timeout: 15 * 60_000, maxBuffer: 20 * 1024 * 1024 }, (err, out, errOut) =>
|
|
354
|
+
err ? reject(new Error(errOut.trim() || err.message)) : resolve(out),
|
|
355
|
+
),
|
|
356
|
+
);
|
|
357
|
+
message = formatDailyReport(JSON.parse(stdout), settings.timeZone);
|
|
358
|
+
} catch (err) {
|
|
359
|
+
log(`daily report ${date}: check failed: ${err instanceof Error ? err.message.slice(0, 500) : String(err)}`);
|
|
360
|
+
message = { content: failureMessage() };
|
|
361
|
+
}
|
|
362
|
+
if (this.stopping) return;
|
|
363
|
+
writeReportState({ date, status: "sending" });
|
|
364
|
+
const posted = await this.rest.createMessage(settings.channelId, {
|
|
365
|
+
content: message.content,
|
|
366
|
+
...(message.attachment ? { file: { name: `arcane-daily-report-${date}.txt`, content: message.attachment } } : {}),
|
|
367
|
+
});
|
|
368
|
+
writeReportState({ date, status: message.content === failureMessage() ? "failed" : "sent", messageId: posted.id });
|
|
369
|
+
log(`daily report ${date}: posted ${posted.id}`);
|
|
370
|
+
} catch (err) {
|
|
371
|
+
log(`daily report ${date}: delivery failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
372
|
+
} finally {
|
|
373
|
+
this.reportRunning = false;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Replied-to and forwarded messages to show the specialist; a bot message only when the resumed conversation does not already hold it. */
|
|
378
|
+
private quotesFor(message: IncomingMessage, inContext: (id: string) => boolean): Quote[] {
|
|
322
379
|
const quotes: Quote[] = [];
|
|
323
380
|
const replied = message.referenced_message;
|
|
324
381
|
if (replied) {
|
|
325
382
|
const mine = replied.author?.id === this.botUserId;
|
|
326
383
|
if (!mine) quotes.push({ label: `Replied-to message from ${replied.author?.username ?? replied.author?.id ?? "an unknown author"}`, message: replied });
|
|
327
|
-
else if (!
|
|
384
|
+
else if (!(replied.id && inContext(replied.id))) quotes.push({ label: "My earlier message, which this message replies to", message: replied });
|
|
328
385
|
}
|
|
329
386
|
for (const snapshot of message.message_snapshots ?? []) quotes.push({ label: "Forwarded message", message: snapshot.message });
|
|
330
387
|
return quotes;
|
package/package.json
CHANGED
package/skills/arcane/SKILL.md
CHANGED
|
@@ -38,6 +38,8 @@ The output is JSON with `counts`, `rows`, and `table`. Answer with the `table` f
|
|
|
38
38
|
- A note "major version change" or "newer major ... available" means the update crosses a major version. Read its release notes yourself before asking anything (next section).
|
|
39
39
|
- Private images (the registry refuses anonymous access, such as our own Grimoire builds) are left out of the table and the counts; the summary line counts them and `privateImages` lists them. Mention them only when the caller asks.
|
|
40
40
|
|
|
41
|
+
Messages that start with "Arcane Daily Report:" are the bridge's scheduled report, built from `update-report` every morning without you. When the caller replies to one, its list is the context ("update these", "why is this unresolved"); run `update-report` again only when the caller wants fresh data or before applying anything.
|
|
42
|
+
|
|
41
43
|
Apply updates only when asked, one stack at a time, through the operations or GitOps procedure above, and run `update-report` again afterwards to show the result.
|
|
42
44
|
|
|
43
45
|
## Release notes
|
package/specialists/README.md
CHANGED
|
@@ -105,7 +105,7 @@ Check: `sudo -n -u specialist -H /usr/local/bin/specialist-cli arcane --caller t
|
|
|
105
105
|
|
|
106
106
|
The `astro-discord` extension gives Discord users access to the specialists from a channel. It runs in a headless Pi host on Cortex (LaunchAgent `com.astrofoundry.astro-discord`, user `cortex`), never in interactive sessions.
|
|
107
107
|
|
|
108
|
-
- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment. When the bridge restarts, open approval requests are marked cancelled and running tasks interrupted in Discord; ask again.
|
|
108
|
+
- Channels are configured one by one (`channels.<id>`): `trigger` is `always` (every message that names a specialist counts) or `mention` (only messages that start with `@Cortex` or reply to the bot); `specialists` is `*` or a list. A channel with one specialist needs no prefix: every message there is a task for it. In a channel with several, `dns list the zones` names the specialist; an addressed message without a name gets a reply asking for one. Threads inherit their parent channel's entry; unlisted channels are ignored. The bot replies at once with "<specialist> is working on your task" and shows the typing indicator until the answer replaces that reply. Reactions on your message: ⏳ running, 🔒 waiting for an owner's approval, ✅ done, ❌ failed. `help` shows the specialists you may use in that channel and the commands (`status` lists running tasks; owners in the admin channel also see the `config` commands). Each channel and specialist has one conversation: a reply to the bot's answer, or another task for that specialist within 30 minutes, continues it with everything the specialist saw before; otherwise a new one starts, and a task beginning with `new` forces that. Replied-to and forwarded messages (text, embeds, attachment names) travel with the task, so "check this" on a forwarded alert works. Image attachments on the message or on what it quotes (up to 4, 8 MiB each) are downloaded and passed to the specialist, which sees them; other file types are named only. Sessions live under `~/.config/astro-discord/sessions/<specialist>/` and are deleted after 7 days. Long answers arrive as a `.md` attachment. When the bridge restarts, open approval requests are marked cancelled and running tasks interrupted in Discord; ask again. With `dailyReport` in the config, the bridge posts an "Arcane Daily Report:" message every morning in that channel: each service with an update as current to target version, from a fresh `update-report`; replying to it asks the arcane specialist with the report as context.
|
|
109
109
|
- Access: `owners` may use every specialist and decide approvals; `access.<specialist>` lists extra users for that specialist; everyone else gets no reply. Owners change the configuration from the admin channel (`adminChannelId`) without a shell: `config show`, `config channel <#channel> <always|mention> <*|dns,edge>`, `config channel <#channel> remove`, `config access <specialist> add|remove <@user>`; the bot writes `~/.config/astro-discord/config.json` on Cortex, which is also editable by hand and re-read on every message. Deny the bot's role View Channel on channels it must never read; that is the boundary the map cannot provide.
|
|
110
110
|
- Approvals: a risky call (anything with `--confirm`, plus writes such as record or zone deletes, Zitadel changes, Pomerium deploys, service restarts, and on Arcane only destructive actions: `down`, deletes, prunes, image, volume, or network removal, `system upgrade`, `gitops delete`; help lookups never ask) pauses the specialist and posts Approve/Deny buttons; only owners' clicks count; no decision within `approvalTimeoutMinutes` denies it. The specialist then reports the denial. Ambiguous tasks get a question back instead of an action.
|
|
111
111
|
- Setup, as `cortex`: `d=$(mktemp -d) && cp ~/.pi/agent/npm/node_modules/@astrofoundry/pi-astro/extensions/astro-discord/*.ts "$d" && node --disable-warning=ExperimentalWarning "$d/setup.ts"` (Node does not strip types inside `node_modules`, so the files are copied first) prompts for the bot token and the ids, checks each against Discord, and writes `~/.config/astro-discord/{token,config.json,run.sh}` and the LaunchAgent; it prints the `launchctl bootstrap` line. After a pi-astro update restart the bridge with `launchctl kickstart -k gui/$(id -u)/com.astrofoundry.astro-discord`. Log: `~/Library/Logs/astro-discord.log`.
|