@wirecat/cli-core 0.390.0 → 0.392.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/CHANGELOG.md +60 -0
- package/dist/cli/bot/command.js +33 -4
- package/dist/cli/bot/port.d.ts +35 -1
- package/dist/cli/bot/token.d.ts +8 -1
- package/dist/cli/bot/token.js +16 -3
- package/dist/cli/bot/watch.d.ts +2 -0
- package/dist/cli/bot/watch.js +54 -12
- package/dist/cli/bot/webhook-receiver.d.ts +22 -0
- package/dist/cli/bot/webhook-receiver.js +68 -0
- package/dist/cli/effects.d.ts +2 -0
- package/dist/cli/effects.js +8 -2
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/messenger/backfill-command.js +5 -1
- package/dist/cli/messenger/catch-up.d.ts +0 -19
- package/dist/cli/messenger/catch-up.js +0 -43
- package/dist/cli/messenger/context.js +6 -3
- package/dist/cli/messenger/conversations-command.js +4 -1
- package/dist/cli/messenger/download-command.js +4 -1
- package/dist/cli/messenger/inbox.js +6 -11
- package/dist/cli/messenger/lacks.d.ts +1 -0
- package/dist/cli/messenger/lacks.js +6 -1
- package/dist/cli/messenger/mcp-command.js +6 -28
- package/dist/cli/messenger/messages-command.js +17 -18
- package/dist/cli/messenger/messages-comments-command.js +4 -1
- package/dist/cli/messenger/messages-delete-command.js +4 -1
- package/dist/cli/messenger/messages-edit-command.js +4 -1
- package/dist/cli/messenger/messages-evidence-command.js +4 -1
- package/dist/cli/messenger/messages-forward-command.js +4 -1
- package/dist/cli/messenger/messages-link-command.js +4 -1
- package/dist/cli/messenger/messages-scheduled-command.js +4 -1
- package/dist/cli/messenger/messages-send-command.js +4 -1
- package/dist/cli/messenger/review.d.ts +1 -1
- package/dist/cli/messenger/review.js +5 -10
- package/dist/cli/messenger/tags-command.js +2 -2
- package/dist/cli/messenger/transcribe-command.js +4 -1
- package/dist/cli/settings.d.ts +0 -4
- package/dist/cli/settings.js +7 -5
- package/dist/domain/providers.js +2 -0
- package/dist/{mcp/http/node.d.ts → http/listen.d.ts} +3 -2
- package/dist/{mcp/http/node.js → http/listen.js} +5 -5
- package/dist/http/public-url.d.ts +10 -0
- package/dist/http/public-url.js +32 -0
- package/dist/mcp/http/serve.d.ts +1 -1
- package/dist/mcp/http/serve.js +1 -1
- package/dist/meetings/index.d.ts +2 -0
- package/dist/meetings/index.js +2 -0
- package/dist/meetings/links.d.ts +18 -0
- package/dist/meetings/links.js +1 -0
- package/dist/meetings/review.d.ts +45 -0
- package/dist/meetings/review.js +1 -0
- package/dist/search/lucene/registry.d.ts +5 -1
- package/dist/search/lucene/registry.js +10 -4
- package/dist/search/lucene/types.d.ts +8 -0
- package/dist/search/lucene/types.js +4 -0
- package/dist/search/question-plan.js +3 -3
- package/dist/services/excerpt.js +4 -1
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.js +2 -0
- package/dist/services/meeting-links.d.ts +33 -0
- package/dist/services/meeting-links.js +41 -0
- package/dist/services/meeting-review.d.ts +12 -0
- package/dist/services/meeting-review.js +8 -0
- package/dist/services/messages-search.js +2 -2
- package/dist/services/searches.js +3 -3
- package/dist/store/sqlite/lucene-fields.d.ts +10 -3
- package/dist/store/sqlite/lucene-fields.js +85 -13
- package/dist/store/sqlite/lucene.js +16 -11
- package/dist/store/sqlite/meeting-chat-lines.d.ts +3 -0
- package/dist/store/sqlite/meeting-chat-lines.js +34 -0
- package/dist/store/sqlite/meeting-review.d.ts +4 -0
- package/dist/store/sqlite/meeting-review.js +97 -0
- package/dist/store/sqlite/meetings.d.ts +2 -2
- package/dist/store/sqlite/meetings.js +4 -0
- package/dist/store/sqlite/mention-drain.js +3 -2
- package/package.json +1 -1
- package/parity.json +339 -118
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,66 @@ Notable changes to `@wirecat/cli-core` (`@wirecat/cli-messaging` up to 0.261.0,
|
|
|
5
5
|
minor version may break callers, and says how under "Changed — may break callers". `pnpm docs:check` checks the
|
|
6
6
|
shape of this file.
|
|
7
7
|
|
|
8
|
+
## 0.392.0 — 23.10.2026
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Message commands include concrete examples in help and command discovery.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **Agents are no longer offered channel comments for a messenger that cannot read them.** Direct calls
|
|
17
|
+
fail before connecting when the messenger declares comments unsupported.
|
|
18
|
+
|
|
19
|
+
## 0.391.0 — 23.10.2026
|
|
20
|
+
|
|
21
|
+
### Changed — may break callers
|
|
22
|
+
|
|
23
|
+
- Reads never mark chats read: `messages list`, `inbox` and `review` no longer take `--mark-read` or
|
|
24
|
+
`--no-mark-read`. Remove `catch_up_marks_read` from configuration; it is now refused. Use
|
|
25
|
+
`<cli> <profile> chats mark-read <chat>` when you intend to send a read receipt.
|
|
26
|
+
- `tags auto` declares its messenger read, including the preparatory read requested with `--sync-first`.
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`bot watch --public-url <url> [--port <n>]` receives a bot's updates by webhook.** For a messenger that pushes and
|
|
31
|
+
cannot be polled — WhatsApp's Cloud API — the command listens on 127.0.0.1 (port 8766 by default) behind the owner's
|
|
32
|
+
tunnel, answers the subscription handshake, refuses a delivery whose signature does not match (401) or that comes
|
|
33
|
+
for another host (403), and answers 200 only after the batch is kept, so the messenger sends again what was not.
|
|
34
|
+
The same copy, redelivery check and `keepUpdates` run as for polling. A messenger opts in with the new
|
|
35
|
+
`BotWebhookIntake` port piece (`webhookHandshake`, `webhookAuthentic`, `webhookEvents`); one without it refuses.
|
|
36
|
+
- **`BotMessenger.secrets` and `bot auth set --secret <name>`** keep a secret beside the bot token, the same way
|
|
37
|
+
(keyring account `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`, then a 0600 file) — a webhook's signing key.
|
|
38
|
+
`BotTokenStore` gains `readSecret`, `writeSecret` and `removeSecret`; `bot auth show` says where each comes from and
|
|
39
|
+
`bot auth remove` forgets them too. Without `secrets` nothing changes.
|
|
40
|
+
- **`--source` knows `whatsapp` (`wa`) and `whatsapp-bot` (`wa-bot`).**
|
|
41
|
+
|
|
42
|
+
- `readMeetingReview` (`@wirecat/cli-core/services`) and `MeetingStore.meetingReview`: the newest meetings of a
|
|
43
|
+
selection, each with its newest summary (overview, next steps), transcript lines and words, the three who talked
|
|
44
|
+
longest and its confirmed projects. No transcript text is read out.
|
|
45
|
+
- `readMeetingLinks` (`@wirecat/cli-core/services`) and `MeetingStore.meetingChatLines`: links shared in the chat of
|
|
46
|
+
the selected meetings, most shared first, with who shared them, in which meetings, and the Google file each names.
|
|
47
|
+
Found when read, not stored; at most 100000 chat messages, more is an error.
|
|
48
|
+
- A local read with an unknown profile now lists the profiles on this computer in its error and
|
|
49
|
+
`details.profiles`, so a typo does not suggest linking a new account. A configured profile with no
|
|
50
|
+
saved account still explains how to fill its local history.
|
|
51
|
+
|
|
52
|
+
- Unknown permission keys now refuse messenger commands with `configuration_error`, including typos after
|
|
53
|
+
the resource name such as `messages.sedn`. Correct the key or remove it with `config unset`; a warning
|
|
54
|
+
no longer leaves the intended restriction unenforced.
|
|
55
|
+
- **Message search reads `"a b"~N`: the words at most N words apart in one message, in any order.** It was a
|
|
56
|
+
`query.unsupported-operator` error; now it is syntax for `text:`, `exact:`, `transcript:`, `content:` and a bare
|
|
57
|
+
word, N up to 50 (`query.invalid-number` above). Words match in any form, as in a phrase; `exact:` keeps the typed
|
|
58
|
+
forms. Unlike Lucene, order costs nothing: `"a b"~0` finds "b a" too. `word~1` stays an error. A saved search may
|
|
59
|
+
now hold `~N`. `QUERY_OPERATORS` splits the `fuzzy / proximity` row into `proximity` and `fuzzy`.
|
|
60
|
+
|
|
61
|
+
### Fixed
|
|
62
|
+
|
|
63
|
+
- **An agent's text on a voice note is its transcript.** `transcript:`, the transcripts-first ranking, `matched_in`
|
|
64
|
+
and a mention's `transcript` field counted only text speech recognition wrote (`speech:<model>`), so a voice note
|
|
65
|
+
an agent transcribed was found as file text. A transcript is now the text of a voice attachment (`voice`,
|
|
66
|
+
`video_note` or `audio/ogg`), whoever wrote it.
|
|
67
|
+
|
|
8
68
|
## 0.389.0 — 22.10.2026
|
|
9
69
|
|
|
10
70
|
### Added
|
package/dist/cli/bot/command.js
CHANGED
|
@@ -19,11 +19,35 @@ import { botCallbacksCommand, botMenuCommand, botWebhooksCommand } from "./setup
|
|
|
19
19
|
import { botStoreCommand } from "./store.js";
|
|
20
20
|
import { BotTokenStore } from "./token.js";
|
|
21
21
|
import { botWatchCommand } from "./watch.js";
|
|
22
|
+
/** Where each of the messenger's other secrets comes from, `null` when it is not set; never the value. */
|
|
23
|
+
const secretSources = (bot, tokens) => bot.secrets?.length
|
|
24
|
+
? { secrets: Object.fromEntries(bot.secrets.map((one) => [one.name, tokens.readSecret(one.name)?.source ?? null])) }
|
|
25
|
+
: {};
|
|
22
26
|
const authCommand = (bot, tokenVariable) => {
|
|
23
27
|
const auth = annotate(new Command("auth"), { mcp: false }).description("the bot token this profile uses");
|
|
24
|
-
grantsAuthority(annotate(auth.command("set"), { effects: LOCAL_WRITE }))
|
|
25
|
-
|
|
26
|
-
|
|
28
|
+
const set = grantsAuthority(annotate(auth.command("set"), { effects: LOCAL_WRITE })).description(`check a bot token with ${bot.name ?? "the messenger"}, then keep it — typed at a hidden prompt or piped on stdin`);
|
|
29
|
+
const secrets = bot.secrets ?? [];
|
|
30
|
+
if (secrets.length > 0)
|
|
31
|
+
set.option("--secret <name>", `keep this secret instead of the token, the same way: ${secrets.map((one) => `${one.name} (${one.description})`).join("; ")}`);
|
|
32
|
+
set.action(async function () {
|
|
33
|
+
const { secret: named } = this.opts();
|
|
34
|
+
if (named !== undefined) {
|
|
35
|
+
const secret = secrets.find((one) => one.name === named);
|
|
36
|
+
if (!secret)
|
|
37
|
+
throw new CliError("validation_error", `no secret called "${named}" — one of: ${secrets.map((one) => one.name).join(", ")}`);
|
|
38
|
+
const context = botContext(this, bot);
|
|
39
|
+
await context.run(async () => {
|
|
40
|
+
const value = (await context.readSecret(`${secret.description}: `)).trim();
|
|
41
|
+
if (!value)
|
|
42
|
+
throw new CliError("validation_error", "no secret given");
|
|
43
|
+
renderItem(this, context, {
|
|
44
|
+
profile: context.profile,
|
|
45
|
+
secret: secret.name,
|
|
46
|
+
stored: context.tokens.writeSecret(secret.name, value),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
27
51
|
const context = online(botContext(this, bot), this);
|
|
28
52
|
await context.run(async (events) => {
|
|
29
53
|
const token = (await context.readSecret("Bot token: ")).trim();
|
|
@@ -57,6 +81,7 @@ const authCommand = (bot, tokenVariable) => {
|
|
|
57
81
|
bot: account.name,
|
|
58
82
|
id: account.id,
|
|
59
83
|
username: account.username,
|
|
84
|
+
...secretSources(bot, context.tokens),
|
|
60
85
|
});
|
|
61
86
|
});
|
|
62
87
|
});
|
|
@@ -64,7 +89,11 @@ const authCommand = (bot, tokenVariable) => {
|
|
|
64
89
|
.description("forget this profile's bot token")
|
|
65
90
|
.action(async function () {
|
|
66
91
|
const context = botContext(this, bot);
|
|
67
|
-
await context.run(async () =>
|
|
92
|
+
await context.run(async () => {
|
|
93
|
+
const removed = context.tokens.remove();
|
|
94
|
+
const secrets = Object.fromEntries((bot.secrets ?? []).map((one) => [one.name, context.tokens.removeSecret(one.name)]));
|
|
95
|
+
renderAnswer(this, context, itemOf({ profile: context.profile, removed, ...(bot.secrets?.length ? { secrets } : {}) }));
|
|
96
|
+
});
|
|
68
97
|
});
|
|
69
98
|
return auth;
|
|
70
99
|
};
|
package/dist/cli/bot/port.d.ts
CHANGED
|
@@ -183,6 +183,38 @@ export interface BotUpdateRef {
|
|
|
183
183
|
export type BotEvent = (MessageEvent | BotNotice) & {
|
|
184
184
|
update?: BotUpdateRef;
|
|
185
185
|
};
|
|
186
|
+
/** One request as it reached `bot watch --public-url`: the body exactly as sent, never parsed and written again. */
|
|
187
|
+
export interface BotWebhookRequest {
|
|
188
|
+
method: string;
|
|
189
|
+
/** Path and query, against the local listener. */
|
|
190
|
+
url: URL;
|
|
191
|
+
headers: Headers;
|
|
192
|
+
body: Uint8Array;
|
|
193
|
+
}
|
|
194
|
+
/** What the receiver answers a request that is not a delivery. */
|
|
195
|
+
export interface BotWebhookAnswer {
|
|
196
|
+
status: number;
|
|
197
|
+
body?: string;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* Receiving the bot's updates on a public address the owner's tunnel forwards to this machine, for a
|
|
201
|
+
* messenger that pushes and cannot be polled.
|
|
202
|
+
*/
|
|
203
|
+
export interface BotWebhookIntake {
|
|
204
|
+
/** A request that is not a delivery — a subscription handshake — answered here; `undefined` for a delivery. */
|
|
205
|
+
webhookHandshake(request: BotWebhookRequest): BotWebhookAnswer | undefined;
|
|
206
|
+
/** Whether a delivery comes from the messenger: its signature over the raw body, compared in constant time. */
|
|
207
|
+
webhookAuthentic(request: BotWebhookRequest): boolean;
|
|
208
|
+
/** The delivery's events, each with the update it came in, so a redelivery is recognised. Throws on a body it cannot read. */
|
|
209
|
+
webhookEvents(request: BotWebhookRequest): BotEvent[];
|
|
210
|
+
}
|
|
211
|
+
/** A secret beside the token that the messenger's bot needs, kept the same way — a webhook's signing key. */
|
|
212
|
+
export interface BotSecret {
|
|
213
|
+
/** In `--secret <name>` and the keyring account, lowercase with hyphens. */
|
|
214
|
+
name: string;
|
|
215
|
+
/** What it is and where the owner finds it, for the help and the prompt. */
|
|
216
|
+
description: string;
|
|
217
|
+
}
|
|
186
218
|
export interface BotUpdatesPage {
|
|
187
219
|
events: BotEvent[];
|
|
188
220
|
/** Where the next call starts; the same as given when nothing came. Opaque to the caller. */
|
|
@@ -200,7 +232,7 @@ export interface BotUpdates {
|
|
|
200
232
|
* The part of the personal core a bot can do, and the groups its Bot API has. A bot never gets
|
|
201
233
|
* `chats` from this type: neither Bot API lists a bot's chats.
|
|
202
234
|
*/
|
|
203
|
-
export type BotAdapter = Pick<MessengerCore, "me" | "close"> & Partial<MarkdownFormatting> & Partial<BotNativeApi> & Partial<BotMessaging> & Partial<BotHistory> & Partial<MessagePins> & Partial<BotChatTools> & Partial<BotChatAdmins> & Partial<BotChatMembers> & Partial<BotMenu> & Partial<BotCallbacks> & Partial<BotWebhooks> & Partial<BotUpdates> & Partial<BotPeople>;
|
|
235
|
+
export type BotAdapter = Pick<MessengerCore, "me" | "close"> & Partial<MarkdownFormatting> & Partial<BotNativeApi> & Partial<BotMessaging> & Partial<BotHistory> & Partial<MessagePins> & Partial<BotChatTools> & Partial<BotChatAdmins> & Partial<BotChatMembers> & Partial<BotMenu> & Partial<BotCallbacks> & Partial<BotWebhooks> & Partial<BotUpdates> & Partial<BotWebhookIntake> & Partial<BotPeople>;
|
|
204
236
|
export interface BotNativeApi {
|
|
205
237
|
api(method: string, input: {
|
|
206
238
|
body?: string;
|
|
@@ -237,6 +269,8 @@ export interface BotMessenger {
|
|
|
237
269
|
adminRights?: readonly AdminRight[];
|
|
238
270
|
/** Whether `bot webhooks set --add` keeps the other addresses; a messenger with one address has no `--add`. */
|
|
239
271
|
manyWebhooks?: boolean;
|
|
272
|
+
/** Secrets beside the token, each set with `bot auth set --secret <name>`; none when unset. */
|
|
273
|
+
secrets?: readonly BotSecret[];
|
|
240
274
|
/** Mount shared bot me; consumers with an existing response migrate explicitly. */
|
|
241
275
|
identity?: boolean;
|
|
242
276
|
/**
|
package/dist/cli/bot/token.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export interface BotTokenStoreOptions {
|
|
|
9
9
|
}
|
|
10
10
|
/**
|
|
11
11
|
* A bot token per profile, beside — never inside — the personal session: keyring account
|
|
12
|
-
* `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file.
|
|
12
|
+
* `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file. A secret the
|
|
13
|
+
* messenger's bot needs beside it lives the same way: `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`.
|
|
13
14
|
*/
|
|
14
15
|
export declare class BotTokenStore {
|
|
15
16
|
#private;
|
|
@@ -27,4 +28,10 @@ export declare class BotTokenStore {
|
|
|
27
28
|
} | undefined;
|
|
28
29
|
writeKeyring(token: string): void;
|
|
29
30
|
remove(): CredentialSource[];
|
|
31
|
+
readSecret(name: string): {
|
|
32
|
+
secret: string;
|
|
33
|
+
source: CredentialSource;
|
|
34
|
+
} | undefined;
|
|
35
|
+
writeSecret(name: string, secret: string): CredentialSource;
|
|
36
|
+
removeSecret(name: string): CredentialSource[];
|
|
30
37
|
}
|
package/dist/cli/bot/token.js
CHANGED
|
@@ -2,26 +2,30 @@ import { CliError, Credentials, pathsAreOverridden, resolvePaths, } from "@wirec
|
|
|
2
2
|
import { envName } from "../../ports/app.js";
|
|
3
3
|
/**
|
|
4
4
|
* A bot token per profile, beside — never inside — the personal session: keyring account
|
|
5
|
-
* `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file.
|
|
5
|
+
* `bot:<profile>` under the app's own service, `<PREFIX>_BOT_TOKEN`, then a 0600 file. A secret the
|
|
6
|
+
* messenger's bot needs beside it lives the same way: `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`.
|
|
6
7
|
*/
|
|
7
8
|
export class BotTokenStore {
|
|
8
9
|
profile;
|
|
9
10
|
#account;
|
|
10
11
|
#credentials;
|
|
11
12
|
#keyringCredentials;
|
|
13
|
+
#secretOf;
|
|
12
14
|
constructor({ app, profile, env = process.env, keyring, configDir }) {
|
|
13
15
|
const where = { appName: app.appName, prefix: app.envPrefix, env };
|
|
14
16
|
this.profile = profile;
|
|
15
17
|
this.#account = `bot:${profile}`;
|
|
16
|
-
|
|
18
|
+
const credentialsFor = (variable) => new Credentials({
|
|
17
19
|
configDir: configDir ?? resolvePaths(where).config,
|
|
18
20
|
service: app.appName,
|
|
19
|
-
envVar: envName(app,
|
|
21
|
+
envVar: envName(app, variable),
|
|
20
22
|
isolated: pathsAreOverridden(where),
|
|
21
23
|
...(keyring ? { keyring } : {}),
|
|
22
24
|
env,
|
|
23
25
|
warn: (message) => process.stderr.write(`${message}\n`),
|
|
24
26
|
});
|
|
27
|
+
this.#credentials = credentialsFor("BOT_TOKEN");
|
|
28
|
+
this.#secretOf = (name) => credentialsFor(`BOT_${name.toUpperCase().replaceAll("-", "_")}`);
|
|
25
29
|
this.#keyringCredentials = new Credentials({
|
|
26
30
|
configDir: configDir ?? resolvePaths(where).config,
|
|
27
31
|
service: app.appName,
|
|
@@ -61,4 +65,13 @@ export class BotTokenStore {
|
|
|
61
65
|
remove() {
|
|
62
66
|
return this.#credentials.remove(this.#account);
|
|
63
67
|
}
|
|
68
|
+
readSecret(name) {
|
|
69
|
+
return this.#secretOf(name).read(`${this.#account}:${name}`);
|
|
70
|
+
}
|
|
71
|
+
writeSecret(name, secret) {
|
|
72
|
+
return this.#secretOf(name).write(`${this.#account}:${name}`, secret);
|
|
73
|
+
}
|
|
74
|
+
removeSecret(name) {
|
|
75
|
+
return this.#secretOf(name).remove(`${this.#account}:${name}`);
|
|
76
|
+
}
|
|
64
77
|
}
|
package/dist/cli/bot/watch.d.ts
CHANGED
|
@@ -5,5 +5,7 @@ import type { BotMessenger } from "./port.js";
|
|
|
5
5
|
* normal end. Each batch is kept before it is printed, and the cursor moves only after that: an end
|
|
6
6
|
* at any moment never loses a batch. An update the messenger names by id is recorded and skipped when it
|
|
7
7
|
* comes again after being handled; one without an id may print twice. A read: it changes nothing anyone sees.
|
|
8
|
+
* With `--public-url` the messenger pushes each batch to a local receiver behind the owner's tunnel
|
|
9
|
+
* instead, and a batch is acknowledged only once kept, so the messenger sends again what was not.
|
|
8
10
|
*/
|
|
9
11
|
export declare const botWatchCommand: (bot: BotMessenger) => Command;
|
package/dist/cli/bot/watch.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { portOf, publicUrlOf } from "../../http/public-url.js";
|
|
4
5
|
import { renderMessages } from "../../render/messages.js";
|
|
5
6
|
import { environmentOf } from "../context.js";
|
|
6
7
|
import { REMOTE_WRITE } from "../effects.js";
|
|
@@ -9,6 +10,7 @@ import { botContext, online } from "./context.js";
|
|
|
9
10
|
import { botCan, botIdOf } from "./messages.js";
|
|
10
11
|
import { botFiles } from "./registry.js";
|
|
11
12
|
import { PressLog, UpdatesCursor } from "./updates.js";
|
|
13
|
+
import { receiveWebhooks, WEBHOOK_PORT } from "./webhook-receiver.js";
|
|
12
14
|
/** Under Telegram's transport timeout of 30 s, so a quiet poll ends as an answer, not as a failure. */
|
|
13
15
|
const WAIT_SECONDS = 25;
|
|
14
16
|
const FIRST_RETRY_MS = 1_000;
|
|
@@ -78,17 +80,31 @@ const keep = async (command, context, bot, botId, events, senders) => {
|
|
|
78
80
|
* normal end. Each batch is kept before it is printed, and the cursor moves only after that: an end
|
|
79
81
|
* at any moment never loses a batch. An update the messenger names by id is recorded and skipped when it
|
|
80
82
|
* comes again after being handled; one without an id may print twice. A read: it changes nothing anyone sees.
|
|
83
|
+
* With `--public-url` the messenger pushes each batch to a local receiver behind the owner's tunnel
|
|
84
|
+
* instead, and a batch is acknowledged only once kept, so the messenger sends again what was not.
|
|
81
85
|
*/
|
|
82
86
|
export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects: REMOTE_WRITE, lifetime: "persistent" })
|
|
83
87
|
.description("print new messages as they arrive and keep them, until Ctrl-C or --timeout (either ends it normally)")
|
|
84
88
|
.option("--events", "also edits, deletions, buttons pressed and people coming and going; every line names its event")
|
|
85
89
|
.option("--types <types>", "only these update types, comma-separated, in the messenger's words")
|
|
90
|
+
.option("--public-url <url>", "receive by webhook at your tunnel's https address instead of polling, e.g. https://<name>.ts.net")
|
|
91
|
+
.option("--port <port>", `the local port behind the tunnel for --public-url (default ${WEBHOOK_PORT})`)
|
|
86
92
|
.action(async function () {
|
|
87
|
-
const
|
|
93
|
+
const flags = this.opts();
|
|
94
|
+
const { events: everything, types: typed } = flags;
|
|
88
95
|
const context = online(botContext(this, bot), this);
|
|
89
96
|
if (context.format === "json") {
|
|
90
97
|
throw new CliError("validation_error", "watch is a stream — use --jsonl for one update per line");
|
|
91
98
|
}
|
|
99
|
+
if (flags.port !== undefined && flags.publicUrl === undefined)
|
|
100
|
+
throw new CliError("validation_error", "--port is the local port behind --public-url; give both");
|
|
101
|
+
const publicUrl = flags.publicUrl === undefined
|
|
102
|
+
? undefined
|
|
103
|
+
: publicUrlOf(flags.publicUrl, {
|
|
104
|
+
missing: "--public-url",
|
|
105
|
+
reaches: `${bot.name ?? "the messenger"} delivers the bot's updates`,
|
|
106
|
+
});
|
|
107
|
+
const port = portOf(flags.port, WEBHOOK_PORT);
|
|
92
108
|
const types = typed
|
|
93
109
|
?.split(",")
|
|
94
110
|
.map((one) => one.trim())
|
|
@@ -152,6 +168,42 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
|
|
|
152
168
|
try {
|
|
153
169
|
await context.run(async (events) => {
|
|
154
170
|
const adapter = await context.authenticated({ events, stop: stop.signal });
|
|
171
|
+
const warn = context.renderer.warn;
|
|
172
|
+
/** Keeps, prints and settles one batch; `false` when it was not kept and must come again. */
|
|
173
|
+
const take = async (botId, batch) => {
|
|
174
|
+
const handled = await context.copy.received(botId, batch, warn);
|
|
175
|
+
const fresh = batch.filter((event) => !(event.update && handled.has(event.update.id)));
|
|
176
|
+
if (fresh.length > 0 && !(await keep(this, context, bot, botId, fresh, adapter.senders?.()))) {
|
|
177
|
+
await context.copy.settle(botId, fresh, "not_kept", warn);
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
for (const event of fresh)
|
|
181
|
+
print(event);
|
|
182
|
+
await context.copy.settle(botId, fresh, null, warn);
|
|
183
|
+
const seen = [...new Set(batch.flatMap((event) => chatOf(event) ?? []))].filter((id) => CHAT_ID.test(id));
|
|
184
|
+
if (seen.length > 0)
|
|
185
|
+
context.registry.observe(seen.map((id) => ({ id })));
|
|
186
|
+
return true;
|
|
187
|
+
};
|
|
188
|
+
if (publicUrl) {
|
|
189
|
+
const intake = {
|
|
190
|
+
webhookHandshake: botCan(adapter, "webhookHandshake", bot, "receive its updates by webhook"),
|
|
191
|
+
webhookAuthentic: botCan(adapter, "webhookAuthentic", bot, "receive its updates by webhook"),
|
|
192
|
+
webhookEvents: botCan(adapter, "webhookEvents", bot, "receive its updates by webhook"),
|
|
193
|
+
};
|
|
194
|
+
const botId = await botIdOf(context, adapter);
|
|
195
|
+
const wanted = (event) => !types || (event.update !== undefined && types.includes(event.update.kind));
|
|
196
|
+
await receiveWebhooks({
|
|
197
|
+
intake,
|
|
198
|
+
publicUrl,
|
|
199
|
+
port,
|
|
200
|
+
take: (batch) => take(botId, batch.filter(wanted)),
|
|
201
|
+
signal: stop.signal,
|
|
202
|
+
warn,
|
|
203
|
+
listening: (local) => context.renderer.note(`receiving at ${publicUrl.origin} through ${local.host} — the tunnel must forward there; Ctrl-C to stop`),
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
155
207
|
const poll = botCan(adapter, "updates", bot, "take updates");
|
|
156
208
|
if (adapter.webhooks && (await adapter.webhooks()).length > 0) {
|
|
157
209
|
throw new CliError("validation_error", `this bot gets its updates by webhook, and ${bot.name ?? "the messenger"} gives polling nothing while one ` +
|
|
@@ -182,21 +234,11 @@ export const botWatchCommand = (bot) => annotate(new Command("watch"), { effects
|
|
|
182
234
|
await retry(`${bot.name ?? "the messenger"} did not answer (${code ?? "unknown"})`);
|
|
183
235
|
continue;
|
|
184
236
|
}
|
|
185
|
-
|
|
186
|
-
const handled = await context.copy.received(botId, page.events, warn);
|
|
187
|
-
const fresh = page.events.filter((event) => !(event.update && handled.has(event.update.id)));
|
|
188
|
-
if (fresh.length > 0 && !(await keep(this, context, bot, botId, fresh, adapter.senders?.()))) {
|
|
189
|
-
await context.copy.settle(botId, fresh, "not_kept", warn);
|
|
237
|
+
if (!(await take(botId, page.events))) {
|
|
190
238
|
await retry("the updates were not kept");
|
|
191
239
|
continue;
|
|
192
240
|
}
|
|
193
241
|
failures = 0;
|
|
194
|
-
for (const event of fresh)
|
|
195
|
-
print(event);
|
|
196
|
-
await context.copy.settle(botId, fresh, null, warn);
|
|
197
|
-
const seen = [...new Set(page.events.flatMap((event) => chatOf(event) ?? []))].filter((id) => CHAT_ID.test(id));
|
|
198
|
-
if (seen.length > 0)
|
|
199
|
-
context.registry.observe(seen.map((id) => ({ id })));
|
|
200
242
|
if (page.cursor !== undefined && page.cursor !== at) {
|
|
201
243
|
at = page.cursor;
|
|
202
244
|
cursor.write(at);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BotEvent, BotWebhookIntake } from "./port.js";
|
|
2
|
+
/** The local port behind the tunnel; MCP's `--http` has 8765. */
|
|
3
|
+
export declare const WEBHOOK_PORT = 8766;
|
|
4
|
+
/** Meta sends at most 3 MB in one delivery; anything larger is refused unread. */
|
|
5
|
+
export declare const WEBHOOK_BODY: number;
|
|
6
|
+
export interface WebhookReceiver {
|
|
7
|
+
intake: BotWebhookIntake;
|
|
8
|
+
publicUrl: URL;
|
|
9
|
+
port: number;
|
|
10
|
+
/** Keeps a batch: `true` once it is safe to tell the messenger it arrived. */
|
|
11
|
+
take: (events: BotEvent[]) => Promise<boolean>;
|
|
12
|
+
signal: AbortSignal;
|
|
13
|
+
/** Where the receiver listens, said once it does. */
|
|
14
|
+
listening: (local: URL) => void;
|
|
15
|
+
warn: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Answers the messenger on 127.0.0.1 until `signal` ends it. A delivery is answered 200 only after
|
|
19
|
+
* `take` kept it: a messenger that pushes retries anything else, so a crash or a full disk loses
|
|
20
|
+
* nothing. Batches are kept one at a time, in the order they arrived.
|
|
21
|
+
*/
|
|
22
|
+
export declare const receiveWebhooks: (receiver: WebhookReceiver) => Promise<void>;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { listen } from "../../http/listen.js";
|
|
2
|
+
/** The local port behind the tunnel; MCP's `--http` has 8765. */
|
|
3
|
+
export const WEBHOOK_PORT = 8766;
|
|
4
|
+
/** Meta sends at most 3 MB in one delivery; anything larger is refused unread. */
|
|
5
|
+
export const WEBHOOK_BODY = 3 * 1024 * 1024;
|
|
6
|
+
const LOCAL = ["localhost", "127.0.0.1"];
|
|
7
|
+
const answer = (status, body = "") => new Response(body, { status, headers: { "content-type": "text/plain; charset=utf-8" } });
|
|
8
|
+
/**
|
|
9
|
+
* Answers the messenger on 127.0.0.1 until `signal` ends it. A delivery is answered 200 only after
|
|
10
|
+
* `take` kept it: a messenger that pushes retries anything else, so a crash or a full disk loses
|
|
11
|
+
* nothing. Batches are kept one at a time, in the order they arrived.
|
|
12
|
+
*/
|
|
13
|
+
export const receiveWebhooks = async (receiver) => {
|
|
14
|
+
const { intake, publicUrl, take, warn } = receiver;
|
|
15
|
+
const hosts = [publicUrl.host, ...LOCAL.map((host) => `${host}:${receiver.port}`), ...LOCAL];
|
|
16
|
+
let queue = Promise.resolve();
|
|
17
|
+
const inTurn = (events) => {
|
|
18
|
+
const turn = queue.then(() => take(events));
|
|
19
|
+
queue = turn.catch(() => undefined);
|
|
20
|
+
return turn;
|
|
21
|
+
};
|
|
22
|
+
const handle = async (incoming) => {
|
|
23
|
+
// A page in the owner's browser can reach 127.0.0.1 too; only the tunnel and the machine itself may.
|
|
24
|
+
if (!hosts.includes(incoming.headers.get("host") ?? ""))
|
|
25
|
+
return answer(403, "host not allowed");
|
|
26
|
+
const url = new URL(incoming.url);
|
|
27
|
+
if (url.pathname !== "/")
|
|
28
|
+
return answer(404, "not found");
|
|
29
|
+
const request = {
|
|
30
|
+
method: incoming.method,
|
|
31
|
+
url,
|
|
32
|
+
headers: incoming.headers,
|
|
33
|
+
body: new Uint8Array(await incoming.arrayBuffer()),
|
|
34
|
+
};
|
|
35
|
+
if (request.method === "GET") {
|
|
36
|
+
const reply = intake.webhookHandshake(request);
|
|
37
|
+
return reply ? answer(reply.status, reply.body) : answer(404, "not found");
|
|
38
|
+
}
|
|
39
|
+
if (request.method !== "POST")
|
|
40
|
+
return answer(405, "method not allowed");
|
|
41
|
+
if (!intake.webhookAuthentic(request))
|
|
42
|
+
return answer(401, "signature does not match");
|
|
43
|
+
let events;
|
|
44
|
+
try {
|
|
45
|
+
events = intake.webhookEvents(request);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return answer(400, "unreadable delivery");
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return (await inTurn(events)) ? answer(200) : answer(503, "not kept");
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
warn(`a delivery was not kept: ${error instanceof Error ? error.message : String(error)}`);
|
|
55
|
+
return answer(503, "not kept");
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const listening = await listen(handle, { host: "127.0.0.1", port: receiver.port, maxBody: WEBHOOK_BODY });
|
|
59
|
+
try {
|
|
60
|
+
receiver.listening(listening.url);
|
|
61
|
+
if (!receiver.signal.aborted)
|
|
62
|
+
await new Promise((resolve) => receiver.signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
63
|
+
await queue;
|
|
64
|
+
}
|
|
65
|
+
finally {
|
|
66
|
+
await listening.close();
|
|
67
|
+
}
|
|
68
|
+
};
|
package/dist/cli/effects.d.ts
CHANGED
|
@@ -36,6 +36,8 @@ export declare const isLogin: (command: Command) => boolean;
|
|
|
36
36
|
* (merge/unmerge, claim/release) or keeps the old value in the task's history; under the lock a close only suggests.
|
|
37
37
|
*/
|
|
38
38
|
export declare const destroysNothing: <T extends Command>(command: T) => T;
|
|
39
|
+
/** A local edit remains an edit even when it first fetches metadata from the messenger. */
|
|
40
|
+
export declare const changesStore: <T extends Command>(command: T) => T;
|
|
39
41
|
/**
|
|
40
42
|
* Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
|
|
41
43
|
* is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
|
package/dist/cli/effects.js
CHANGED
|
@@ -52,6 +52,12 @@ export const destroysNothing = (command) => {
|
|
|
52
52
|
KEPT_UNDER_LOCK.add(command);
|
|
53
53
|
return command;
|
|
54
54
|
};
|
|
55
|
+
const STORE_WRITES = new WeakSet();
|
|
56
|
+
/** A local edit remains an edit even when it first fetches metadata from the messenger. */
|
|
57
|
+
export const changesStore = (command) => {
|
|
58
|
+
STORE_WRITES.add(command);
|
|
59
|
+
return command;
|
|
60
|
+
};
|
|
55
61
|
/**
|
|
56
62
|
* Whether `<PREFIX>_READ_ONLY` refuses the command as a change to the store. Every write that stays on this machine
|
|
57
63
|
* is refused unless `destroysNothing` marks it, so a new one fails closed (WC-PERM-12). A fetch (`remote: "read"`)
|
|
@@ -59,8 +65,8 @@ export const destroysNothing = (command) => {
|
|
|
59
65
|
*/
|
|
60
66
|
export const isStoreChange = (command) => {
|
|
61
67
|
const effects = metaOf(command).effects;
|
|
62
|
-
return (effects?.remote === "none" &&
|
|
63
|
-
effects
|
|
68
|
+
return ((effects?.remote === "none" || STORE_WRITES.has(command)) &&
|
|
69
|
+
effects?.local === "write" &&
|
|
64
70
|
!KEPT_UNDER_LOCK.has(command) &&
|
|
65
71
|
!changesAuthority(command));
|
|
66
72
|
};
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -42,7 +42,7 @@ export { type BotContext, botContext, botWords } from "./bot/context.js";
|
|
|
42
42
|
export { type BotCopy, botCopy } from "./bot/copy.js";
|
|
43
43
|
export { botMcpCommand } from "./bot/mcp.js";
|
|
44
44
|
export { botCan, botIdOf, botMessagesCommand } from "./bot/messages.js";
|
|
45
|
-
export { BOT_ACTIONS, type BotAction, type BotAdapter, type BotCallbacks, type BotChatAdmin, type BotChatAdmins, type BotChatMembers, type BotChatRef, type BotChatTools, type BotConnectOptions, type BotEvent, type BotHistory, type BotMcp, type BotMenu, type BotMenuEntry, type BotMessaging, type BotMessenger, type BotNativeApi, type BotNotice, type BotPeople, type BotPress, type BotSendOptions, type BotUpdates, type BotUpdatesPage, type BotWebhook, type BotWebhooks, } from "./bot/port.js";
|
|
45
|
+
export { BOT_ACTIONS, type BotAction, type BotAdapter, type BotCallbacks, type BotChatAdmin, type BotChatAdmins, type BotChatMembers, type BotChatRef, type BotChatTools, type BotConnectOptions, type BotEvent, type BotHistory, type BotMcp, type BotMenu, type BotMenuEntry, type BotMessaging, type BotMessenger, type BotNativeApi, type BotNotice, type BotPeople, type BotPress, type BotSecret, type BotSendOptions, type BotUpdates, type BotUpdatesPage, type BotWebhook, type BotWebhookAnswer, type BotWebhookIntake, type BotWebhookRequest, type BotWebhooks, } from "./bot/port.js";
|
|
46
46
|
export { botFiles, botsDirectory, ChatRegistry, registryProfiles, type SeenChat } from "./bot/registry.js";
|
|
47
47
|
export { BotTokenStore, type BotTokenStoreOptions } from "./bot/token.js";
|
|
48
48
|
export { PressLog, UpdatesCursor } from "./bot/updates.js";
|
|
@@ -34,7 +34,11 @@ const ALL_SINCE = "90d";
|
|
|
34
34
|
const ALL_CHATS = "--all";
|
|
35
35
|
export const syncCommand = (messenger) => {
|
|
36
36
|
const fetching = messenger.fetching ?? FETCHING;
|
|
37
|
-
return annotate(new Command("sync"), {
|
|
37
|
+
return annotate(new Command("sync"), {
|
|
38
|
+
examples: [`${messenger.app.command} personal messages sync "Book club" --last 100`],
|
|
39
|
+
effects: FETCH,
|
|
40
|
+
cooperative: true,
|
|
41
|
+
})
|
|
38
42
|
.description("bring a chat's history in the local store up to date, newest first; run it again to continue; --all syncs every chat")
|
|
39
43
|
.argument("[chat]", messenger.chatArgument)
|
|
40
44
|
.option("--all", `every chat, most recently active first — what search needs; the last ${ALL_SINCE} unless --since-time or --last`)
|
|
@@ -1,22 +1,3 @@
|
|
|
1
1
|
import type { Command } from "commander";
|
|
2
|
-
import type { Id, Message } from "../../domain/models.js";
|
|
3
|
-
import type { MarkedRead } from "../../services/chats.js";
|
|
4
|
-
import type { Services } from "../../services/index.js";
|
|
5
|
-
import type { Settings } from "../settings.js";
|
|
6
|
-
export declare const MARK_READ_OPTION: readonly ["--mark-read", "also mark each chat shown read, up to the newest message shown; the other side sees it"];
|
|
7
|
-
export declare const NO_MARK_READ_OPTION: readonly ["--no-mark-read", "do not, whatever the catch_up_marks_read setting says"];
|
|
8
|
-
/**
|
|
9
|
-
* The typed flag decides; without one, the owner's `catch_up_marks_read` setting, off unless turned on (NEED-566).
|
|
10
|
-
* An agent's run never marks read on that setting: the other side sees it, so only an agent that asks does.
|
|
11
|
-
*/
|
|
12
|
-
export declare const marksRead: (typed: boolean | undefined, settings: Settings, command: Command) => boolean;
|
|
13
2
|
/** Whose `--new` points a run moves: an agent's are its own, so the owner's `inbox --new` still shows everything. */
|
|
14
3
|
export declare const pointsOf: <N extends "inbox" | "review">(command: Command, name: N) => N | `mcp-${N}`;
|
|
15
|
-
/**
|
|
16
|
-
* Marks each chat read up to the newest message shown in it — never further, so what arrived after
|
|
17
|
-
* the read stays unread. Through the send guard, so `permissions.chats.mark-read` applies.
|
|
18
|
-
*/
|
|
19
|
-
export declare const markShown: (services: Services, chats: {
|
|
20
|
-
id: Id;
|
|
21
|
-
messages: Pick<Message, "id" | "timestamp">[];
|
|
22
|
-
}[], warn: (message: string) => void) => Promise<MarkedRead[]>;
|
|
@@ -1,46 +1,3 @@
|
|
|
1
|
-
import { CliError, isCliError } from "@wirecat/cli-base";
|
|
2
1
|
import { environmentOf } from "../context.js";
|
|
3
|
-
export const MARK_READ_OPTION = [
|
|
4
|
-
"--mark-read",
|
|
5
|
-
"also mark each chat shown read, up to the newest message shown; the other side sees it",
|
|
6
|
-
];
|
|
7
|
-
export const NO_MARK_READ_OPTION = ["--no-mark-read", "do not, whatever the catch_up_marks_read setting says"];
|
|
8
|
-
// Neither is offered to an agent (`ownerOnlyOption`): a read tool may run unasked, and the other side sees a mark.
|
|
9
|
-
// An agent marks read with `chats mark-read`, a write.
|
|
10
|
-
/**
|
|
11
|
-
* The typed flag decides; without one, the owner's `catch_up_marks_read` setting, off unless turned on (NEED-566).
|
|
12
|
-
* An agent's run never marks read on that setting: the other side sees it, so only an agent that asks does.
|
|
13
|
-
*/
|
|
14
|
-
export const marksRead = (typed, settings, command) => {
|
|
15
|
-
const marking = typed ?? (environmentOf(command).agent ? false : settings.catchUpMarksRead);
|
|
16
|
-
if (marking && settings.offline) {
|
|
17
|
-
throw new CliError("validation_error", "--mark-read tells the messenger; not with --offline");
|
|
18
|
-
}
|
|
19
|
-
return marking;
|
|
20
|
-
};
|
|
21
2
|
/** Whose `--new` points a run moves: an agent's are its own, so the owner's `inbox --new` still shows everything. */
|
|
22
3
|
export const pointsOf = (command, name) => environmentOf(command).agent ? `mcp-${name}` : name;
|
|
23
|
-
/**
|
|
24
|
-
* Marks each chat read up to the newest message shown in it — never further, so what arrived after
|
|
25
|
-
* the read stays unread. Through the send guard, so `permissions.chats.mark-read` applies.
|
|
26
|
-
*/
|
|
27
|
-
export const markShown = async (services, chats, warn) => {
|
|
28
|
-
const marked = [];
|
|
29
|
-
for (const { id, messages } of chats) {
|
|
30
|
-
const newest = messages.reduce((last, one) => (last === undefined || Date.parse(one.timestamp) >= Date.parse(last.timestamp) ? one : last), undefined);
|
|
31
|
-
if (newest === undefined)
|
|
32
|
-
continue;
|
|
33
|
-
try {
|
|
34
|
-
const { chatId, until } = await services.chats.markRead({ chat: id, until: newest.id });
|
|
35
|
-
marked.push({ chatId, until });
|
|
36
|
-
}
|
|
37
|
-
catch (error) {
|
|
38
|
-
// A hold refuses every chat alike; what was read is still the answer, as reads go on during one.
|
|
39
|
-
if (!isCliError(error) || error.details.sendBlock === undefined)
|
|
40
|
-
throw error;
|
|
41
|
-
warn(`${error.message}; nothing more was marked read`);
|
|
42
|
-
break;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return marked;
|
|
46
|
-
};
|
|
@@ -282,7 +282,10 @@ export const connectedFor = async (command, messenger, base, options) => {
|
|
|
282
282
|
}),
|
|
283
283
|
};
|
|
284
284
|
};
|
|
285
|
-
const unrecorded = (command, messenger, profile, offline) => {
|
|
285
|
+
const unrecorded = (command, messenger, profile, { offline, profilesHere }) => {
|
|
286
|
+
if (!profilesHere.includes(profile)) {
|
|
287
|
+
return new CliError("not_found", `no profile named "${profile}" — profiles here: ${profilesHere.length > 0 ? profilesHere.join(", ") : "none"}`, { profiles: profilesHere });
|
|
288
|
+
}
|
|
286
289
|
const words = `${messenger.app.command} ${asFirstWord(profile)}`;
|
|
287
290
|
const setup = rootOf(command).commands.some((one) => one.name() === "setup") ? `\`${words}setup\`` : undefined;
|
|
288
291
|
if (messenger.history === "store")
|
|
@@ -346,14 +349,14 @@ export const messengerContext = (command, messenger, resolving = {}) => {
|
|
|
346
349
|
refuseDenied(command, base.settings);
|
|
347
350
|
refuseLacking(messenger, pathOf(command));
|
|
348
351
|
for (const key of unknownPermissionKeys(base.settings.permissions, knownPermissionKeys(command, messenger.permissionKey))) {
|
|
349
|
-
|
|
352
|
+
throw new CliError("configuration_error", `permissions.${key} names no command — remove it with \`${app.command} ${asFirstWord(profile)}config unset permissions.${key}\``, { permission: key });
|
|
350
353
|
}
|
|
351
354
|
// Read when warning, not now: serve swaps the renderer's methods for log records after this.
|
|
352
355
|
const warn = (text, warning) => base.renderer.warn(text, warning);
|
|
353
356
|
const accountFor = () => {
|
|
354
357
|
const account = messenger.accountOf?.(command) ?? recalledAccount(app, provider, profile, base.env);
|
|
355
358
|
if (!account)
|
|
356
|
-
throw unrecorded(command, messenger, profile, base.settings
|
|
359
|
+
throw unrecorded(command, messenger, profile, base.settings);
|
|
357
360
|
return account;
|
|
358
361
|
};
|
|
359
362
|
const guard = messenger.guard?.(command, base.settings, warn) ??
|
|
@@ -473,7 +473,10 @@ const answerOf = (text) => {
|
|
|
473
473
|
return answer;
|
|
474
474
|
};
|
|
475
475
|
/** `messages links`: why a message sits where it does in its conversation. */
|
|
476
|
-
export const linksCommand = (messenger) => annotate(new Command("links"), {
|
|
476
|
+
export const linksCommand = (messenger) => annotate(new Command("links"), {
|
|
477
|
+
examples: [`${messenger.app.command} personal messages links "Book club" 101`],
|
|
478
|
+
effects: LOCAL_READ,
|
|
479
|
+
})
|
|
477
480
|
.description("why a message is in its conversation: each link it has, and the chain of answers back to the start")
|
|
478
481
|
.argument("<chat>", messenger.chatArgument)
|
|
479
482
|
.argument("<message>", "the message id")
|