dsh-mishu 0.6.2 → 0.7.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.
- package/README.md +22 -6
- package/lib/client.js +629 -365
- package/lib/core/channels.js +142 -0
- package/lib/core/index.js +2 -0
- package/lib/core/notify.js +198 -0
- package/lib/dsh/api.js +47 -1
- package/lib/dsh/plugin.js +4 -0
- package/lib/dsh/runtime.js +39 -1
- package/lib/types/core/channels.d.ts +61 -0
- package/lib/types/core/index.d.ts +2 -0
- package/lib/types/core/notify.d.ts +97 -0
- package/lib/types/dsh/runtime.d.ts +10 -1
- package/package.json +1 -1
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where reminders go.
|
|
3
|
+
*
|
|
4
|
+
* desktop this computer's own notifications: macOS through osascript,
|
|
5
|
+
* Windows through a PowerShell toast, Linux through notify-send.
|
|
6
|
+
* A server without a desktop has none and says so.
|
|
7
|
+
* feishu a custom bot in a Feishu / Lark group (just you is fine). Its
|
|
8
|
+
* webhook address and optional signing secret are secrets: they
|
|
9
|
+
* live in the keychain (or the secrets file), never the notebook.
|
|
10
|
+
*
|
|
11
|
+
* Text reaches the helper programs as arguments to a fixed script or on
|
|
12
|
+
* stdin, never inside the script itself, so nothing in a title can run.
|
|
13
|
+
*/
|
|
14
|
+
import { createHmac } from 'node:crypto';
|
|
15
|
+
import { findProgram, runProgram } from "./exec.js";
|
|
16
|
+
import { bi, MishuError } from "./i18n.js";
|
|
17
|
+
const one = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
18
|
+
export class DesktopChannel {
|
|
19
|
+
id = 'desktop';
|
|
20
|
+
platform;
|
|
21
|
+
env;
|
|
22
|
+
constructor(platform = process.platform, env = process.env) {
|
|
23
|
+
this.platform = platform;
|
|
24
|
+
this.env = env;
|
|
25
|
+
}
|
|
26
|
+
async status() {
|
|
27
|
+
if (this.platform === 'darwin')
|
|
28
|
+
return findProgram('osascript') === undefined ? { available: false, why: bi('没找到 osascript', 'osascript was not found') } : { available: true };
|
|
29
|
+
if (this.platform === 'win32')
|
|
30
|
+
return findProgram('powershell') === undefined ? { available: false, why: bi('没找到 Windows PowerShell', 'Windows PowerShell was not found') } : { available: true };
|
|
31
|
+
if (this.env.DISPLAY === undefined && this.env.WAYLAND_DISPLAY === undefined) {
|
|
32
|
+
return { available: false, why: bi('这台电脑没有桌面(比如服务器),弹不出电脑通知。用飞书就行。', 'This computer has no desktop (a server, say), so it cannot show notifications. Use Feishu instead.') };
|
|
33
|
+
}
|
|
34
|
+
return findProgram('notify-send') === undefined
|
|
35
|
+
? { available: false, why: bi('没找到 notify-send(Debian/Ubuntu:sudo apt install libnotify-bin)', 'notify-send was not found (Debian/Ubuntu: sudo apt install libnotify-bin)') }
|
|
36
|
+
: { available: true };
|
|
37
|
+
}
|
|
38
|
+
async send(message) {
|
|
39
|
+
const title = one(message.title, 80);
|
|
40
|
+
const body = one(message.body, 200);
|
|
41
|
+
let result;
|
|
42
|
+
if (this.platform === 'darwin') {
|
|
43
|
+
result = await runProgram(findProgram('osascript'), ['-e', 'on run argv', '-e', 'display notification (item 2 of argv) with title (item 1 of argv) sound name "default"', '-e', 'end run', title, body]);
|
|
44
|
+
}
|
|
45
|
+
else if (this.platform === 'win32') {
|
|
46
|
+
result = await runProgram(findProgram('powershell'), ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_TOAST], `${title}\n${body}`);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
result = await runProgram(findProgram('notify-send'), ['-a', 'DSH', title, body]);
|
|
50
|
+
}
|
|
51
|
+
if (result.code !== 0)
|
|
52
|
+
throw new Error(`notification failed (${(result.stderr.trim() || result.failure || `exit ${result.code}`).slice(0, 200)})`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// Title on the first line of stdin, body after it; shown as Windows PowerShell's toast.
|
|
56
|
+
const WINDOWS_TOAST = [
|
|
57
|
+
'$t=[Console]::In.ReadLine();$b=[Console]::In.ReadToEnd();',
|
|
58
|
+
'[void][Windows.UI.Notifications.ToastNotificationManager,Windows.UI.Notifications,ContentType=WindowsRuntime];',
|
|
59
|
+
'$x=[Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02);',
|
|
60
|
+
"$n=$x.GetElementsByTagName('text');[void]$n.Item(0).AppendChild($x.CreateTextNode($t));[void]$n.Item(1).AppendChild($x.CreateTextNode($b));",
|
|
61
|
+
"[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\\WindowsPowerShell\\v1.0\\powershell.exe').Show([Windows.UI.Notifications.ToastNotification]::new($x))",
|
|
62
|
+
].join('');
|
|
63
|
+
export const FEISHU_WEBHOOK = 'feishu:webhook';
|
|
64
|
+
export const FEISHU_SECRET = 'feishu:secret';
|
|
65
|
+
const WEBHOOK = /^https:\/\/open\.(feishu\.cn|larksuite\.com)\/open-apis\/bot\/v2\/hook\/[\w-]{8,}$/;
|
|
66
|
+
/** Feishu's errors for a custom bot, said so you know what to change in the group's bot settings. */
|
|
67
|
+
function feishuError(code, msg) {
|
|
68
|
+
if (code === 19024)
|
|
69
|
+
return bi('飞书拒收:消息里没有机器人设置的关键词。把关键词设成"小秘书",或者改用签名校验。', 'Feishu refused it: the message lacks the bot\'s keyword. Set the keyword to "小秘书" or use signature verification instead.');
|
|
70
|
+
if (code === 19021)
|
|
71
|
+
return bi('飞书拒收:签名不对。把机器人"签名校验"里的密钥重新复制过来。', 'Feishu refused it: wrong signature. Copy the bot\'s signing secret again.');
|
|
72
|
+
if (code === 19022)
|
|
73
|
+
return bi('飞书拒收:这台电脑的 IP 不在机器人的 IP 白名单里。', 'Feishu refused it: this computer\'s IP is not on the bot\'s allowlist.');
|
|
74
|
+
if (code === 19001 || /not found|invalid/i.test(msg))
|
|
75
|
+
return bi('飞书找不到这个机器人:地址抄错了,或者机器人已经被删掉。', 'Feishu does not know this bot: the address is mistyped or the bot was removed.');
|
|
76
|
+
return bi(`飞书拒收(${code}:${msg})`, `Feishu refused it (${code}: ${msg})`);
|
|
77
|
+
}
|
|
78
|
+
export class FeishuChannel {
|
|
79
|
+
id = 'feishu';
|
|
80
|
+
secrets;
|
|
81
|
+
post;
|
|
82
|
+
constructor(secrets, post) {
|
|
83
|
+
this.secrets = secrets;
|
|
84
|
+
this.post = post ?? globalThis.fetch;
|
|
85
|
+
}
|
|
86
|
+
async status() {
|
|
87
|
+
return await this.secrets.get(FEISHU_WEBHOOK).catch(() => undefined) === undefined
|
|
88
|
+
? { available: false, why: bi('还没填飞书机器人的地址。', 'No Feishu bot address yet.') }
|
|
89
|
+
: { available: true };
|
|
90
|
+
}
|
|
91
|
+
async send(message) {
|
|
92
|
+
const webhook = await this.secrets.get(FEISHU_WEBHOOK);
|
|
93
|
+
if (webhook === undefined)
|
|
94
|
+
throw new MishuError(bi('还没填飞书机器人的地址。', 'No Feishu bot address yet.'));
|
|
95
|
+
await sendFeishu(this.post, webhook, await this.secrets.get(FEISHU_SECRET), message.text);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function sendFeishu(post, webhook, secret, text, now = Date.now()) {
|
|
99
|
+
const timestamp = String(Math.floor(now / 1000));
|
|
100
|
+
// Feishu signs with HMAC-SHA256 keyed by "timestamp\nsecret" over an empty message.
|
|
101
|
+
const signed = secret === undefined ? {} : { timestamp, sign: createHmac('sha256', `${timestamp}\n${secret}`).update('').digest('base64') };
|
|
102
|
+
const response = await post(webhook, {
|
|
103
|
+
method: 'POST',
|
|
104
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
105
|
+
body: JSON.stringify({ ...signed, msg_type: 'text', content: { text } }),
|
|
106
|
+
});
|
|
107
|
+
const raw = await response.text();
|
|
108
|
+
let answer;
|
|
109
|
+
try {
|
|
110
|
+
answer = JSON.parse(raw);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
throw new MishuError(bi(`飞书没有正常回应(HTTP ${response.status})`, `Feishu did not answer properly (HTTP ${response.status})`));
|
|
114
|
+
}
|
|
115
|
+
const code = answer.code ?? answer.StatusCode ?? 0;
|
|
116
|
+
if (code !== 0)
|
|
117
|
+
throw new MishuError(feishuError(code, answer.msg ?? answer.StatusMessage ?? ''));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Keep a bot address (and its signing secret, if the bot uses one) after
|
|
121
|
+
* sending a first message through it, so a wrong address never gets saved.
|
|
122
|
+
*/
|
|
123
|
+
export async function saveFeishu(secrets, input, lang, post) {
|
|
124
|
+
const webhook = input.webhook.trim();
|
|
125
|
+
const secret = input.secret?.trim() || undefined;
|
|
126
|
+
if (!WEBHOOK.test(webhook)) {
|
|
127
|
+
throw new MishuError(bi('这不像飞书机器人的地址:应该以 https://open.feishu.cn/open-apis/bot/v2/hook/ 开头。', 'That is not a Feishu bot address: it starts with https://open.feishu.cn/open-apis/bot/v2/hook/.'));
|
|
128
|
+
}
|
|
129
|
+
const status = await secrets.status();
|
|
130
|
+
if (!status.available)
|
|
131
|
+
throw new MishuError(bi(`没法保存:${status.why.zh}`, `Cannot save it: ${status.why.en}`));
|
|
132
|
+
await sendFeishu(post ?? globalThis.fetch, webhook, secret, lang === 'zh' ? '小秘书已经连上这个群,以后的提醒会发到这里。' : 'The secretary is connected to this group; reminders will come here.');
|
|
133
|
+
await secrets.set(FEISHU_WEBHOOK, webhook);
|
|
134
|
+
if (secret === undefined)
|
|
135
|
+
await secrets.delete(FEISHU_SECRET);
|
|
136
|
+
else
|
|
137
|
+
await secrets.set(FEISHU_SECRET, secret);
|
|
138
|
+
}
|
|
139
|
+
export async function deleteFeishu(secrets) {
|
|
140
|
+
await secrets.delete(FEISHU_WEBHOOK);
|
|
141
|
+
await secrets.delete(FEISHU_SECRET);
|
|
142
|
+
}
|
package/lib/core/index.js
CHANGED
|
@@ -24,6 +24,8 @@ export { ImapFetcher } from "./mail/imap.js";
|
|
|
24
24
|
export { judgeMail } from "./mail/rules.js";
|
|
25
25
|
export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail } from "./mail/sync.js";
|
|
26
26
|
export { findProgram } from "./exec.js";
|
|
27
|
+
export { deleteFeishu, DesktopChannel, FeishuChannel, saveFeishu, FEISHU_WEBHOOK } from "./channels.js";
|
|
28
|
+
export { DEFAULT_NOTIFY, deliver, inQuiet, localClock, notifySettings, notifyTick, NOTIFY_REPORT_KEY, saveNotifySettings, testMessage, URGENT_REASONS, } from "./notify.js";
|
|
27
29
|
export { restartPlan, scheduleRestart } from "./restart.js";
|
|
28
30
|
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets } from "./secrets.js";
|
|
29
31
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings } from "./settings.js";
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching you without you opening the board: a daily brief, urgent items
|
|
3
|
+
* as they come, and quiet hours.
|
|
4
|
+
*
|
|
5
|
+
* Urgent means someone is waiting on you or it is due today:
|
|
6
|
+
* - things you told it: due today or overdue
|
|
7
|
+
* - GitHub: a review requested, a mention, an assignment
|
|
8
|
+
* - mail: a meeting invitation
|
|
9
|
+
* Everything else waits for the daily brief. Each urgent item is sent once
|
|
10
|
+
* (again only when it changes reason or due day); turning reminders on does
|
|
11
|
+
* not send what was already on the board. During quiet hours nothing is
|
|
12
|
+
* sent; what came up is sent when they end.
|
|
13
|
+
*
|
|
14
|
+
* Times are read in the time zone your browser reported, so a server
|
|
15
|
+
* running on UTC still sends the brief at your morning.
|
|
16
|
+
*/
|
|
17
|
+
import { reasonLabel } from "./brief.js";
|
|
18
|
+
import { bi, decode, MishuError } from "./i18n.js";
|
|
19
|
+
import { isActive } from "./matters.js";
|
|
20
|
+
import { dayOf } from "./time.js";
|
|
21
|
+
export const DEFAULT_NOTIFY = {
|
|
22
|
+
brief: { enabled: true, time: '09:00' },
|
|
23
|
+
urgent: true,
|
|
24
|
+
quiet: { enabled: true, from: '22:00', to: '08:00' },
|
|
25
|
+
timeZone: '',
|
|
26
|
+
channels: { desktop: true, feishu: false },
|
|
27
|
+
};
|
|
28
|
+
export const URGENT_REASONS = ['review-requested', 'mentioned', 'assigned', 'mail-invite'];
|
|
29
|
+
const SETTINGS_KEY = 'notify.settings';
|
|
30
|
+
const STATE_KEY = 'notify.state';
|
|
31
|
+
export const NOTIFY_REPORT_KEY = 'notify.last';
|
|
32
|
+
export function notifySettings(store) {
|
|
33
|
+
const saved = store.get(SETTINGS_KEY) ?? {};
|
|
34
|
+
return {
|
|
35
|
+
brief: { ...DEFAULT_NOTIFY.brief, ...saved.brief },
|
|
36
|
+
urgent: saved.urgent ?? DEFAULT_NOTIFY.urgent,
|
|
37
|
+
quiet: { ...DEFAULT_NOTIFY.quiet, ...saved.quiet },
|
|
38
|
+
timeZone: saved.timeZone ?? DEFAULT_NOTIFY.timeZone,
|
|
39
|
+
channels: { ...DEFAULT_NOTIFY.channels, ...saved.channels },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
43
|
+
/** Check what the page sent and keep it; throws a sentence a person can act on. */
|
|
44
|
+
export function saveNotifySettings(store, input, now) {
|
|
45
|
+
const current = notifySettings(store);
|
|
46
|
+
const next = {
|
|
47
|
+
brief: { ...current.brief, ...input.brief },
|
|
48
|
+
urgent: input.urgent ?? current.urgent,
|
|
49
|
+
quiet: { ...current.quiet, ...input.quiet },
|
|
50
|
+
timeZone: input.timeZone ?? current.timeZone,
|
|
51
|
+
channels: { ...current.channels, ...input.channels },
|
|
52
|
+
};
|
|
53
|
+
for (const time of [next.brief.time, next.quiet.from, next.quiet.to]) {
|
|
54
|
+
if (!TIME.test(time))
|
|
55
|
+
throw new MishuError(bi(`时间要写成 08:30 这样:${time}`, `Write times like 08:30: ${time}`));
|
|
56
|
+
}
|
|
57
|
+
if (next.timeZone !== '') {
|
|
58
|
+
try {
|
|
59
|
+
new Intl.DateTimeFormat('en', { timeZone: next.timeZone });
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new MishuError(bi(`不认识这个时区:${next.timeZone}`, `Unknown time zone: ${next.timeZone}`));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
store.set(SETTINGS_KEY, next);
|
|
66
|
+
store.log(now, 'notify-settings', next);
|
|
67
|
+
return next;
|
|
68
|
+
}
|
|
69
|
+
/** The day and minute of the day in a time zone ('' = this computer's). */
|
|
70
|
+
export function localClock(now, timeZone) {
|
|
71
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
72
|
+
...timeZone === '' ? {} : { timeZone },
|
|
73
|
+
year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
|
74
|
+
}).formatToParts(new Date(now));
|
|
75
|
+
const part = (type) => parts.find(p => p.type === type)?.value ?? '00';
|
|
76
|
+
return { day: `${part('year')}-${part('month')}-${part('day')}`, minutes: Number(part('hour')) * 60 + Number(part('minute')) };
|
|
77
|
+
}
|
|
78
|
+
const minutesOf = (time) => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5));
|
|
79
|
+
/** Whether a minute of the day is inside quiet hours, which may run past midnight. */
|
|
80
|
+
export function inQuiet(minutes, quiet) {
|
|
81
|
+
if (!quiet.enabled)
|
|
82
|
+
return false;
|
|
83
|
+
const from = minutesOf(quiet.from);
|
|
84
|
+
const to = minutesOf(quiet.to);
|
|
85
|
+
if (from === to)
|
|
86
|
+
return false;
|
|
87
|
+
return from < to ? minutes >= from && minutes < to : minutes >= from || minutes < to;
|
|
88
|
+
}
|
|
89
|
+
/** Open items someone is waiting on, or due today. */
|
|
90
|
+
export function urgentMatters(store, today) {
|
|
91
|
+
return store.openMatters().filter(m => isActive(m, today) && (m.source === 'manual' ? m.dueDay !== null && m.dueDay <= today : URGENT_REASONS.includes(m.reason ?? '')));
|
|
92
|
+
}
|
|
93
|
+
const urgentKey = (m) => `${m.id}:${m.reason ?? ''}:${m.dueDay ?? ''}`;
|
|
94
|
+
const SIGN = bi('打开小秘书看板处理。', 'Open the secretary board to handle them.');
|
|
95
|
+
function urgentMessage(matters, lang) {
|
|
96
|
+
const zh = lang === 'zh';
|
|
97
|
+
const line = (m) => {
|
|
98
|
+
const why = m.source === 'manual' ? (zh ? `${m.dueDay} 到期` : `due ${m.dueDay}`) : reasonLabel(m.reason, lang);
|
|
99
|
+
return `• ${decode(m.title, lang)}${why === '' ? '' : `(${why})`}`;
|
|
100
|
+
};
|
|
101
|
+
const first = decode(matters[0].title, lang);
|
|
102
|
+
return {
|
|
103
|
+
kind: 'urgent',
|
|
104
|
+
title: zh ? `小秘书:${matters.length} 件急事` : `Secretary: ${matters.length} urgent`,
|
|
105
|
+
body: matters.length === 1 ? first : (zh ? `${first} 等 ${matters.length} 件` : `${first} and ${matters.length - 1} more`),
|
|
106
|
+
text: [zh ? `小秘书:${matters.length} 件急事` : `Secretary: ${matters.length} urgent`, ...matters.slice(0, 10).map(line),
|
|
107
|
+
...matters.length > 10 ? [zh ? `…还有 ${matters.length - 10} 件` : `…and ${matters.length - 10} more`] : [], SIGN[lang]].join('\n'),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The brief, from what the board shows; `undefined` when there is nothing to tell. */
|
|
111
|
+
export function briefMessage(board, lang) {
|
|
112
|
+
const zh = lang === 'zh';
|
|
113
|
+
const { handle, remember } = board.columns;
|
|
114
|
+
if (handle.length === 0 && board.metrics.dueSoon === 0)
|
|
115
|
+
return undefined;
|
|
116
|
+
const lines = handle.slice(0, 10).map(card => `• ${card.title}(${card.meta})`);
|
|
117
|
+
const soon = remember.filter(card => card.tone !== 'normal').slice(0, 5).map(card => `• ${card.title}(${card.meta})`);
|
|
118
|
+
const title = zh ? '小秘书 · 今日简报' : 'Secretary · daily brief';
|
|
119
|
+
return {
|
|
120
|
+
kind: 'brief',
|
|
121
|
+
title,
|
|
122
|
+
body: board.summary,
|
|
123
|
+
text: [title, board.summary,
|
|
124
|
+
...lines.length > 0 ? ['', zh ? '要你处理:' : 'To handle:', ...lines] : [],
|
|
125
|
+
...handle.length > 10 ? [zh ? `…还有 ${handle.length - 10} 件` : `…and ${handle.length - 10} more`] : [],
|
|
126
|
+
...soon.length > 0 ? ['', zh ? '快到期:' : 'Coming up:', ...soon] : [],
|
|
127
|
+
'', SIGN[lang]].join('\n'),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function testMessage(lang) {
|
|
131
|
+
return lang === 'zh'
|
|
132
|
+
? { kind: 'test', title: '小秘书', body: '提醒能送到这里。', text: '小秘书:提醒能送到这里。' }
|
|
133
|
+
: { kind: 'test', title: 'Secretary', body: 'Reminders reach you here.', text: 'Secretary: reminders reach you here.' };
|
|
134
|
+
}
|
|
135
|
+
/** Send to every enabled channel; one failing does not stop the others. */
|
|
136
|
+
export async function deliver(store, channels, message, count, now) {
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const channel of channels) {
|
|
139
|
+
try {
|
|
140
|
+
await channel.send(message);
|
|
141
|
+
results.push({ channel: channel.id, ok: true });
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
results.push({ channel: channel.id, ok: false, error: error instanceof MishuError ? error.text.en : error.message });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const report = { at: now, kind: message.kind, count, results };
|
|
148
|
+
if (message.kind !== 'test')
|
|
149
|
+
store.set(NOTIFY_REPORT_KEY, report);
|
|
150
|
+
// Counts and channels only: titles never go to the log.
|
|
151
|
+
store.log(now, 'notify', { kind: message.kind, count, results: results.map(r => ({ channel: r.channel, ok: r.ok })) });
|
|
152
|
+
return report;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* One look at the clock: send what is due. Called every minute and after
|
|
156
|
+
* each sync. `channels` are the enabled ones; `board` builds the brief.
|
|
157
|
+
*/
|
|
158
|
+
export async function notifyTick(options) {
|
|
159
|
+
const { store, now, lang, channels } = options;
|
|
160
|
+
const settings = notifySettings(store);
|
|
161
|
+
if (channels.length === 0)
|
|
162
|
+
return [];
|
|
163
|
+
const clock = localClock(now, settings.timeZone);
|
|
164
|
+
const today = dayOf(now);
|
|
165
|
+
const urgent = urgentMatters(store, today);
|
|
166
|
+
let state = store.get(STATE_KEY);
|
|
167
|
+
if (state === undefined) {
|
|
168
|
+
// First look: what is already on the board is not news, and a brief whose time has passed waits for tomorrow.
|
|
169
|
+
state = { sent: Object.fromEntries(urgent.map(m => [urgentKey(m), now])) };
|
|
170
|
+
if (clock.minutes >= minutesOf(settings.brief.time))
|
|
171
|
+
state.briefDay = clock.day;
|
|
172
|
+
store.set(STATE_KEY, state);
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
const reports = [];
|
|
176
|
+
if (!inQuiet(clock.minutes, settings.quiet)) {
|
|
177
|
+
const fresh = settings.urgent ? urgent.filter(m => state.sent[urgentKey(m)] === undefined) : [];
|
|
178
|
+
if (fresh.length > 0) {
|
|
179
|
+
reports.push(await deliver(store, channels, urgentMessage(fresh, lang), fresh.length, now));
|
|
180
|
+
for (const m of fresh)
|
|
181
|
+
state.sent[urgentKey(m)] = now;
|
|
182
|
+
}
|
|
183
|
+
if (settings.brief.enabled && state.briefDay !== clock.day && clock.minutes >= minutesOf(settings.brief.time)) {
|
|
184
|
+
state.briefDay = clock.day;
|
|
185
|
+
const board = options.board();
|
|
186
|
+
const message = briefMessage(board, lang);
|
|
187
|
+
if (message !== undefined)
|
|
188
|
+
reports.push(await deliver(store, channels, message, board.columns.handle.length, now));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
// Forget what was sent for items closed a week ago.
|
|
192
|
+
const open = new Set(urgent.map(urgentKey));
|
|
193
|
+
for (const [key, at] of Object.entries(state.sent))
|
|
194
|
+
if (!open.has(key) && now - at > 7 * 86_400_000)
|
|
195
|
+
delete state.sent[key];
|
|
196
|
+
store.set(STATE_KEY, state);
|
|
197
|
+
return reports;
|
|
198
|
+
}
|
package/lib/dsh/api.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* travel inward: they go straight to the keychain, and no endpoint ever
|
|
10
10
|
* returns one.
|
|
11
11
|
*/
|
|
12
|
-
import { addDays, addSeries, bi, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
|
|
12
|
+
import { addDays, addSeries, bi, deleteFeishu, deliver, FEISHU_WEBHOOK, notifySettings, NOTIFY_REPORT_KEY, saveFeishu, saveNotifySettings, testMessage, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
|
|
13
13
|
import { resolveAccount } from "../core/mail/account.js";
|
|
14
14
|
export const API_BASE = '/api/dsh-mishu';
|
|
15
15
|
class ApiError extends Error {
|
|
@@ -108,6 +108,15 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
108
108
|
...secrets.available && secrets.file !== undefined ? { file: { path: secrets.file.path, why: secrets.file.why[lang] } } : {},
|
|
109
109
|
},
|
|
110
110
|
accounts,
|
|
111
|
+
notify: {
|
|
112
|
+
settings: notifySettings(store),
|
|
113
|
+
channels: await Promise.all(['desktop', 'feishu'].map(async (id) => {
|
|
114
|
+
const status = await runtime.channels[id].status();
|
|
115
|
+
return { id, enabled: notifySettings(store).channels[id], available: status.available, ...status.available ? {} : { why: status.why[lang] } };
|
|
116
|
+
})),
|
|
117
|
+
feishu: { configured: (await runtime.secrets.get(FEISHU_WEBHOOK).catch(() => undefined)) !== undefined },
|
|
118
|
+
last: store.get(NOTIFY_REPORT_KEY) ?? null,
|
|
119
|
+
},
|
|
111
120
|
presets: MAIL_PRESETS.map(p => ({
|
|
112
121
|
id: p.id, label: p.label[lang], host: p.host, port: p.port, secure: p.secure, supported: p.supported, howTo: p.howTo[lang],
|
|
113
122
|
domains: p.domains, passwordLength: p.passwordLength ?? null,
|
|
@@ -234,6 +243,43 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
234
243
|
await deleteGithubToken(runtime.secrets);
|
|
235
244
|
return { view: await settingsView(langOf(body)) };
|
|
236
245
|
},
|
|
246
|
+
// ── reminders ─────────────────────────────────────────
|
|
247
|
+
'/notify/save': async (body) => {
|
|
248
|
+
const lang = langOf(body);
|
|
249
|
+
saveNotifySettings(need(lang), (body.notify ?? {}), clock());
|
|
250
|
+
return { view: await settingsView(lang) };
|
|
251
|
+
},
|
|
252
|
+
/** One test message through one channel, turned on or not. */
|
|
253
|
+
'/notify/test': async (body) => {
|
|
254
|
+
const lang = langOf(body);
|
|
255
|
+
const id = String(body.channel);
|
|
256
|
+
const channel = runtime.channels[id];
|
|
257
|
+
if (channel === undefined)
|
|
258
|
+
throw new ApiError(lang === 'zh' ? '没有这个渠道' : 'No such channel');
|
|
259
|
+
const status = await channel.status();
|
|
260
|
+
if (!status.available)
|
|
261
|
+
throw new ApiError(status.why[lang]);
|
|
262
|
+
const report = await deliver(need(lang), [channel], testMessage(lang), 0, clock());
|
|
263
|
+
const failed = report.results.find(r => !r.ok);
|
|
264
|
+
if (failed !== undefined)
|
|
265
|
+
throw new ApiError(failed.error ?? '');
|
|
266
|
+
return { sent: true };
|
|
267
|
+
},
|
|
268
|
+
/** A Feishu bot: tried with a first message, then kept as a secret; reminders go there from now on. */
|
|
269
|
+
'/feishu/save': async (body) => {
|
|
270
|
+
const lang = langOf(body);
|
|
271
|
+
const store = need(lang);
|
|
272
|
+
await saveFeishu(runtime.secrets, { webhook: String(body.webhook ?? ''), ...typeof body.secret === 'string' ? { secret: body.secret } : {} }, lang);
|
|
273
|
+
saveNotifySettings(store, { channels: { ...notifySettings(store).channels, feishu: true } }, clock());
|
|
274
|
+
return { view: await settingsView(lang) };
|
|
275
|
+
},
|
|
276
|
+
'/feishu/delete': async (body) => {
|
|
277
|
+
const lang = langOf(body);
|
|
278
|
+
const store = need(lang);
|
|
279
|
+
await deleteFeishu(runtime.secrets);
|
|
280
|
+
saveNotifySettings(store, { channels: { ...notifySettings(store).channels, feishu: false } }, clock());
|
|
281
|
+
return { view: await settingsView(lang) };
|
|
282
|
+
},
|
|
237
283
|
'/mail/save': async (body) => {
|
|
238
284
|
const lang = langOf(body);
|
|
239
285
|
const account = await saveMailAccount(need(lang), runtime.secrets, accountInput(body.account), password(body), clock());
|
package/lib/dsh/plugin.js
CHANGED
|
@@ -64,12 +64,16 @@ export function apply(ctx, config, runtime = new Runtime(config, ctx.logger)) {
|
|
|
64
64
|
firstSync.unref();
|
|
65
65
|
const syncTimer = setInterval(() => { void runtime.syncAll(); }, config.github.intervalMinutes * 60_000);
|
|
66
66
|
syncTimer.unref();
|
|
67
|
+
// Reminders look at the clock every minute: the brief's time, the end of quiet hours.
|
|
68
|
+
const notifyTimer = setInterval(() => { void runtime.notify(); }, 60_000);
|
|
69
|
+
notifyTimer.unref();
|
|
67
70
|
const settings = runtime.settings();
|
|
68
71
|
ctx.logger.info(`dsh-mishu: notebook ${paths.db}; language ${runtime.lang()}; github ${settings.githubEnabled ? `every ${config.github.intervalMinutes} min, mark read ${settings.markRead}` : 'off'}; ${runtime.accounts().length} mail account(s); secrets in ${runtime.secrets.kind}`);
|
|
69
72
|
return async () => {
|
|
70
73
|
clearInterval(tickTimer);
|
|
71
74
|
clearTimeout(firstSync);
|
|
72
75
|
clearInterval(syncTimer);
|
|
76
|
+
clearInterval(notifyTimer);
|
|
73
77
|
await Promise.race([runtime.idle(), new Promise(resolve => setTimeout(resolve, 5000).unref())]);
|
|
74
78
|
opened.close();
|
|
75
79
|
runtime.store = undefined;
|
package/lib/dsh/runtime.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* what you set on the settings page on top), the keychain, and one sync of
|
|
5
5
|
* every source at a time.
|
|
6
6
|
*/
|
|
7
|
-
import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, FileSecrets, GithubDeviceLogin, KeychainOrFile, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, } from "../core/index.js";
|
|
7
|
+
import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, } from "../core/index.js";
|
|
8
8
|
export class Runtime {
|
|
9
9
|
store;
|
|
10
10
|
config;
|
|
@@ -12,6 +12,9 @@ export class Runtime {
|
|
|
12
12
|
fetcher;
|
|
13
13
|
logger;
|
|
14
14
|
syncing;
|
|
15
|
+
/** Where reminders can go; tests swap them for recorders. */
|
|
16
|
+
channels;
|
|
17
|
+
notifying;
|
|
15
18
|
/** "Sign in with GitHub" in progress, if any; tests swap it to replay GitHub. */
|
|
16
19
|
githubLogin;
|
|
17
20
|
/** How DSH restarts; tests swap it so they never restart anything. */
|
|
@@ -21,6 +24,7 @@ export class Runtime {
|
|
|
21
24
|
this.logger = logger;
|
|
22
25
|
this.secrets = secrets;
|
|
23
26
|
this.githubLogin = new GithubDeviceLogin({ clientId: config.github.clientId, secrets });
|
|
27
|
+
this.channels = { desktop: new DesktopChannel(), feishu: new FeishuChannel(secrets) };
|
|
24
28
|
this.fetcher = fetcher;
|
|
25
29
|
}
|
|
26
30
|
defaults() {
|
|
@@ -105,6 +109,38 @@ export class Runtime {
|
|
|
105
109
|
this.logger.info(`dsh-mishu: mail #${account.id} ${report.error === undefined ? `ok (${report.messages} unread, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
|
|
106
110
|
return report;
|
|
107
111
|
}
|
|
112
|
+
/** The channels that are turned on and work on this computer. */
|
|
113
|
+
async activeChannels() {
|
|
114
|
+
const store = this.store;
|
|
115
|
+
if (store === undefined)
|
|
116
|
+
return [];
|
|
117
|
+
const enabled = notifySettings(store).channels;
|
|
118
|
+
const active = [];
|
|
119
|
+
for (const id of Object.keys(enabled)) {
|
|
120
|
+
if (enabled[id] && (await this.channels[id].status()).available)
|
|
121
|
+
active.push(this.channels[id]);
|
|
122
|
+
}
|
|
123
|
+
return active;
|
|
124
|
+
}
|
|
125
|
+
/** The board as the brief describes it. */
|
|
126
|
+
board(now, lang = this.lang()) {
|
|
127
|
+
const settings = this.settings();
|
|
128
|
+
return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts() });
|
|
129
|
+
}
|
|
130
|
+
/** Send what is due now: urgent items, the daily brief. One at a time. */
|
|
131
|
+
notify(now = Date.now()) {
|
|
132
|
+
const store = this.store;
|
|
133
|
+
if (store === undefined)
|
|
134
|
+
return Promise.resolve([]);
|
|
135
|
+
this.notifying ??= (async () => {
|
|
136
|
+
const lang = this.lang();
|
|
137
|
+
return notifyTick({ store, now, lang, channels: await this.activeChannels(), board: () => this.board(now, lang) });
|
|
138
|
+
})().catch((error) => {
|
|
139
|
+
this.logger.warn(`dsh-mishu: reminders failed (${error.message})`);
|
|
140
|
+
return [];
|
|
141
|
+
}).finally(() => { this.notifying = undefined; });
|
|
142
|
+
return this.notifying;
|
|
143
|
+
}
|
|
108
144
|
/** Every source once; concurrent callers share the sync in flight. */
|
|
109
145
|
syncAll() {
|
|
110
146
|
const store = this.store;
|
|
@@ -115,6 +151,8 @@ export class Runtime {
|
|
|
115
151
|
for (const account of this.accounts().filter(a => a.enabled)) {
|
|
116
152
|
await this.syncMailAccount(account).catch(error => this.logger.warn(`dsh-mishu: mail #${account.id} sync failed (${error.message})`));
|
|
117
153
|
}
|
|
154
|
+
// What the sync turned up may be urgent.
|
|
155
|
+
await this.notify();
|
|
118
156
|
})().finally(() => { this.syncing = undefined; });
|
|
119
157
|
return this.syncing;
|
|
120
158
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where reminders go.
|
|
3
|
+
*
|
|
4
|
+
* desktop this computer's own notifications: macOS through osascript,
|
|
5
|
+
* Windows through a PowerShell toast, Linux through notify-send.
|
|
6
|
+
* A server without a desktop has none and says so.
|
|
7
|
+
* feishu a custom bot in a Feishu / Lark group (just you is fine). Its
|
|
8
|
+
* webhook address and optional signing secret are secrets: they
|
|
9
|
+
* live in the keychain (or the secrets file), never the notebook.
|
|
10
|
+
*
|
|
11
|
+
* Text reaches the helper programs as arguments to a fixed script or on
|
|
12
|
+
* stdin, never inside the script itself, so nothing in a title can run.
|
|
13
|
+
*/
|
|
14
|
+
import { type Bi } from './i18n.ts';
|
|
15
|
+
import type { Channel, Message } from './notify.ts';
|
|
16
|
+
import type { SecretStore } from './secrets.ts';
|
|
17
|
+
export declare class DesktopChannel implements Channel {
|
|
18
|
+
readonly id: "desktop";
|
|
19
|
+
private readonly platform;
|
|
20
|
+
private readonly env;
|
|
21
|
+
constructor(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv);
|
|
22
|
+
status(): Promise<{
|
|
23
|
+
available: true;
|
|
24
|
+
} | {
|
|
25
|
+
available: false;
|
|
26
|
+
why: Bi;
|
|
27
|
+
}>;
|
|
28
|
+
send(message: Message): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export declare const FEISHU_WEBHOOK = "feishu:webhook";
|
|
31
|
+
export declare const FEISHU_SECRET = "feishu:secret";
|
|
32
|
+
export type JsonPost = (url: string, init: {
|
|
33
|
+
method: 'POST';
|
|
34
|
+
headers: Record<string, string>;
|
|
35
|
+
body: string;
|
|
36
|
+
}) => Promise<{
|
|
37
|
+
status: number;
|
|
38
|
+
text(): Promise<string>;
|
|
39
|
+
}>;
|
|
40
|
+
export declare class FeishuChannel implements Channel {
|
|
41
|
+
readonly id: "feishu";
|
|
42
|
+
private readonly secrets;
|
|
43
|
+
private readonly post;
|
|
44
|
+
constructor(secrets: SecretStore, post?: JsonPost);
|
|
45
|
+
status(): Promise<{
|
|
46
|
+
available: true;
|
|
47
|
+
} | {
|
|
48
|
+
available: false;
|
|
49
|
+
why: Bi;
|
|
50
|
+
}>;
|
|
51
|
+
send(message: Message): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Keep a bot address (and its signing secret, if the bot uses one) after
|
|
55
|
+
* sending a first message through it, so a wrong address never gets saved.
|
|
56
|
+
*/
|
|
57
|
+
export declare function saveFeishu(secrets: SecretStore, input: {
|
|
58
|
+
webhook: string;
|
|
59
|
+
secret?: string;
|
|
60
|
+
}, lang: 'zh' | 'en', post?: JsonPost): Promise<void>;
|
|
61
|
+
export declare function deleteFeishu(secrets: SecretStore): Promise<void>;
|
|
@@ -23,6 +23,8 @@ export { ImapFetcher } from './mail/imap.ts';
|
|
|
23
23
|
export { judgeMail } from './mail/rules.ts';
|
|
24
24
|
export { describeMailError, MAIL_SOURCE, mailReportKey, syncMail, type MailSyncReport } from './mail/sync.ts';
|
|
25
25
|
export { findProgram } from './exec.ts';
|
|
26
|
+
export { deleteFeishu, DesktopChannel, FeishuChannel, saveFeishu, FEISHU_WEBHOOK } from './channels.ts';
|
|
27
|
+
export { DEFAULT_NOTIFY, deliver, inQuiet, localClock, notifySettings, notifyTick, NOTIFY_REPORT_KEY, saveNotifySettings, testMessage, URGENT_REASONS, type Channel, type ChannelId, type Message, type NotifyReport, type NotifySettings, } from './notify.ts';
|
|
26
28
|
export { restartPlan, scheduleRestart, type RestartPlan, type RestartStarted } from './restart.ts';
|
|
27
29
|
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStatus, type SecretStore } from './secrets.ts';
|
|
28
30
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings, type Defaults, type UserSettings } from './settings.ts';
|