@wirecat/cli-core 0.391.0 → 0.393.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 +41 -1
- 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/index.d.ts +1 -1
- package/dist/cli/messenger/backfill-command.js +5 -1
- package/dist/cli/messenger/chats-command.js +50 -0
- package/dist/cli/messenger/contacts-command.js +31 -1
- package/dist/cli/messenger/conversations-command.js +4 -1
- package/dist/cli/messenger/download-command.js +4 -1
- 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 +12 -3
- 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/tags-command.js +14 -4
- package/dist/cli/messenger/transcribe-command.js +4 -1
- 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/store/sqlite/lucene-fields.d.ts +7 -2
- package/dist/store/sqlite/lucene-fields.js +7 -2
- package/dist/store/sqlite/mention-drain.js +3 -2
- package/package.json +3 -3
- package/parity.json +339 -107
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,28 @@ 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
|
-
##
|
|
8
|
+
## 0.393.0 — 23.10.2026
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Tag commands include concrete examples in help and command discovery.
|
|
13
|
+
|
|
14
|
+
- Chat commands include concrete examples in help and command discovery.
|
|
15
|
+
- Contact commands include concrete examples in help and command discovery.
|
|
16
|
+
- **`@wirecat/cli-base` 0.50.0 is accepted beside 0.49.** The peer range is now `>=0.49.0 <0.51.0`, so a CLI that
|
|
17
|
+
takes cli-base 0.50.0 for `adaptOpenApi` (whatsapp-cli's bot API) keeps one copy of cli-base instead of two.
|
|
18
|
+
Nothing in cli-core calls what 0.50.0 added.
|
|
19
|
+
|
|
20
|
+
## 0.392.0 — 23.10.2026
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- Message commands include concrete examples in help and command discovery.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- **Agents are no longer offered channel comments for a messenger that cannot read them.** Direct calls
|
|
29
|
+
fail before connecting when the messenger declares comments unsupported.
|
|
9
30
|
|
|
10
31
|
## 0.391.0 — 23.10.2026
|
|
11
32
|
|
|
@@ -18,6 +39,18 @@ shape of this file.
|
|
|
18
39
|
|
|
19
40
|
### Added
|
|
20
41
|
|
|
42
|
+
- **`bot watch --public-url <url> [--port <n>]` receives a bot's updates by webhook.** For a messenger that pushes and
|
|
43
|
+
cannot be polled — WhatsApp's Cloud API — the command listens on 127.0.0.1 (port 8766 by default) behind the owner's
|
|
44
|
+
tunnel, answers the subscription handshake, refuses a delivery whose signature does not match (401) or that comes
|
|
45
|
+
for another host (403), and answers 200 only after the batch is kept, so the messenger sends again what was not.
|
|
46
|
+
The same copy, redelivery check and `keepUpdates` run as for polling. A messenger opts in with the new
|
|
47
|
+
`BotWebhookIntake` port piece (`webhookHandshake`, `webhookAuthentic`, `webhookEvents`); one without it refuses.
|
|
48
|
+
- **`BotMessenger.secrets` and `bot auth set --secret <name>`** keep a secret beside the bot token, the same way
|
|
49
|
+
(keyring account `bot:<profile>:<name>`, `<PREFIX>_BOT_<NAME>`, then a 0600 file) — a webhook's signing key.
|
|
50
|
+
`BotTokenStore` gains `readSecret`, `writeSecret` and `removeSecret`; `bot auth show` says where each comes from and
|
|
51
|
+
`bot auth remove` forgets them too. Without `secrets` nothing changes.
|
|
52
|
+
- **`--source` knows `whatsapp` (`wa`) and `whatsapp-bot` (`wa-bot`).**
|
|
53
|
+
|
|
21
54
|
- `readMeetingReview` (`@wirecat/cli-core/services`) and `MeetingStore.meetingReview`: the newest meetings of a
|
|
22
55
|
selection, each with its newest summary (overview, next steps), transcript lines and words, the three who talked
|
|
23
56
|
longest and its confirmed projects. No transcript text is read out.
|
|
@@ -37,6 +70,13 @@ shape of this file.
|
|
|
37
70
|
forms. Unlike Lucene, order costs nothing: `"a b"~0` finds "b a" too. `word~1` stays an error. A saved search may
|
|
38
71
|
now hold `~N`. `QUERY_OPERATORS` splits the `fuzzy / proximity` row into `proximity` and `fuzzy`.
|
|
39
72
|
|
|
73
|
+
### Fixed
|
|
74
|
+
|
|
75
|
+
- **An agent's text on a voice note is its transcript.** `transcript:`, the transcripts-first ranking, `matched_in`
|
|
76
|
+
and a mention's `transcript` field counted only text speech recognition wrote (`speech:<model>`), so a voice note
|
|
77
|
+
an agent transcribed was found as file text. A transcript is now the text of a voice attachment (`voice`,
|
|
78
|
+
`video_note` or `audio/ogg`), whoever wrote it.
|
|
79
|
+
|
|
40
80
|
## 0.389.0 — 22.10.2026
|
|
41
81
|
|
|
42
82
|
### 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/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`)
|
|
@@ -6,6 +6,7 @@ import { LOCAL_READ, REMOTE_READ } from "../effects.js";
|
|
|
6
6
|
import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
7
7
|
import { withQuery } from "../list-query.js";
|
|
8
8
|
import { withPaging } from "../paging.js";
|
|
9
|
+
import { commandAt } from "../steps.js";
|
|
9
10
|
import { groupCommands } from "./admin-commands.js";
|
|
10
11
|
import { foldersCommand } from "./admin-folders-command.js";
|
|
11
12
|
import { moderateCommand, rulesCommand } from "./admin-moderation-command.js";
|
|
@@ -102,6 +103,55 @@ export const chatsCommand = (messenger) => {
|
|
|
102
103
|
if (messenger.personalBots === true)
|
|
103
104
|
for (const command of botChatCommands(messenger))
|
|
104
105
|
chats.addCommand(command);
|
|
106
|
+
const examples = {
|
|
107
|
+
"admins add": `"Book club" "Alex Example"${messenger.pickAdminRights === false ? "" : ` --can ${messenger.adminRights?.[0] ?? "info"}`}`,
|
|
108
|
+
"admins remove": '"Book club" "Alex Example"',
|
|
109
|
+
create: '"Book club" "Alex Example"',
|
|
110
|
+
"documents list": '"Project room" --limit 10',
|
|
111
|
+
events: '"Book club"',
|
|
112
|
+
"folders create": '"Reading" --chat "Book club"',
|
|
113
|
+
"folders delete": '"Reading"',
|
|
114
|
+
"folders join": "https://t.me/addlist/example",
|
|
115
|
+
"folders list": "",
|
|
116
|
+
"folders order": '"Reading" "Work"',
|
|
117
|
+
"folders show": '"Reading"',
|
|
118
|
+
"folders update": '"Reading" --title "Books"',
|
|
119
|
+
inspect: "https://example.com/invite/book-club",
|
|
120
|
+
join: "https://example.com/invite/book-club",
|
|
121
|
+
leave: '"Book club"',
|
|
122
|
+
"link create": '"Book club"',
|
|
123
|
+
"link list": '"Book club"',
|
|
124
|
+
"link reset": '"Book club"',
|
|
125
|
+
"link revoke": '"Book club" https://example.com/invite/book-club',
|
|
126
|
+
"link show": '"Book club"',
|
|
127
|
+
list: "--limit 10",
|
|
128
|
+
"mark-read": '"Book club" --until-id 101',
|
|
129
|
+
"members add": '"Book club" "Alex Example"',
|
|
130
|
+
"members audit": '"Book club"',
|
|
131
|
+
"members fetch": '"Book club"',
|
|
132
|
+
"members history": '"Book club"',
|
|
133
|
+
"members list": '"Book club"',
|
|
134
|
+
"members remove": '"Book club" "Alex Example"',
|
|
135
|
+
moderate: '"Book club" --dry-run',
|
|
136
|
+
"requests accept": '"Book club" "Alex Example"',
|
|
137
|
+
"requests decline": '"Book club" "Alex Example"',
|
|
138
|
+
"requests list": '"Book club"',
|
|
139
|
+
"rules set": '"Book club" links report',
|
|
140
|
+
"rules show": '"Book club"',
|
|
141
|
+
"rules unset": '"Book club" links',
|
|
142
|
+
"send-as": '"Project room"',
|
|
143
|
+
show: '"Book club"',
|
|
144
|
+
"tracking add": '"Book club"',
|
|
145
|
+
"tracking list": "",
|
|
146
|
+
"tracking remove": '"Book club"',
|
|
147
|
+
"tracking show": '"Book club"',
|
|
148
|
+
update: '"Book club" --title "Reading group"',
|
|
149
|
+
};
|
|
150
|
+
for (const [path, args] of Object.entries(examples)) {
|
|
151
|
+
const command = commandAt(chats, path);
|
|
152
|
+
if (command)
|
|
153
|
+
annotate(command, { examples: [`${messenger.app.command} personal chats ${path} ${args}`.trimEnd()] });
|
|
154
|
+
}
|
|
105
155
|
return chats;
|
|
106
156
|
};
|
|
107
157
|
/** Where the history is pushed, the chat list and a chat's card answer from the store and never connect. */
|
|
@@ -15,7 +15,7 @@ import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
|
15
15
|
import { listQuery, nativeTerms, typedQuery, withQuery } from "../list-query.js";
|
|
16
16
|
import { positiveCount, window, withPaging } from "../paging.js";
|
|
17
17
|
import { casKey } from "../registry-keys.js";
|
|
18
|
-
import { nextStep } from "../steps.js";
|
|
18
|
+
import { commandAt, nextStep } from "../steps.js";
|
|
19
19
|
import { contactWriteCommands } from "./admin-contacts-command.js";
|
|
20
20
|
import { contactAnswer, lines, pageOf } from "./chat-answers.js";
|
|
21
21
|
import { messengerContext } from "./context.js";
|
|
@@ -250,5 +250,35 @@ export const contactsCommand = (messenger) => {
|
|
|
250
250
|
contacts.addCommand(command);
|
|
251
251
|
for (const command of contactWriteCommands(messenger))
|
|
252
252
|
contacts.addCommand(command);
|
|
253
|
+
const examples = {
|
|
254
|
+
add: '"Alex Example"',
|
|
255
|
+
"alias rm": '"Alex Example"',
|
|
256
|
+
"alias set": '"Alex Example" "Reading partner"',
|
|
257
|
+
block: '"Alex Example"',
|
|
258
|
+
check: '"Alex Example"',
|
|
259
|
+
context: '"Alex Example" --limit 10',
|
|
260
|
+
import: "./contacts.csv",
|
|
261
|
+
link: '"Alex Example" zoom:101',
|
|
262
|
+
list: "--limit 10",
|
|
263
|
+
lookup: "",
|
|
264
|
+
"notes add": '"Alex Example" --file ./note.txt',
|
|
265
|
+
"notes edit": '"Alex Example" 1 --revision 1 --file ./note.txt',
|
|
266
|
+
"notes list": '"Alex Example"',
|
|
267
|
+
"notes remove": '"Alex Example" 1',
|
|
268
|
+
"notes show": '"Alex Example" 1',
|
|
269
|
+
profile: '"Alex Example"',
|
|
270
|
+
remove: '"Alex Example"',
|
|
271
|
+
rename: '"Alex Example" Alex Example',
|
|
272
|
+
show: '"Alex Example"',
|
|
273
|
+
sync: "",
|
|
274
|
+
timeline: '"Alex Example" --limit 10',
|
|
275
|
+
unblock: '"Alex Example"',
|
|
276
|
+
unlink: '"Alex Example"',
|
|
277
|
+
};
|
|
278
|
+
for (const [path, args] of Object.entries(examples)) {
|
|
279
|
+
const command = commandAt(contacts, path);
|
|
280
|
+
if (command)
|
|
281
|
+
annotate(command, { examples: [`${messenger.app.command} personal contacts ${path} ${args}`.trimEnd()] });
|
|
282
|
+
}
|
|
253
283
|
return contacts;
|
|
254
284
|
};
|
|
@@ -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")
|
|
@@ -17,7 +17,10 @@ import { nextStep } from "../steps.js";
|
|
|
17
17
|
import { messengerContext, refuseLocalWrite } from "./context.js";
|
|
18
18
|
import { stopOnSignal } from "./patience.js";
|
|
19
19
|
/** `messages download`: every file of one message, or with `--all` of a whole chat, never over a file already there. */
|
|
20
|
-
export const downloadSubcommand = (messages, messenger) => annotate(messages.command("download"), {
|
|
20
|
+
export const downloadSubcommand = (messages, messenger) => annotate(messages.command("download"), {
|
|
21
|
+
examples: [`${messenger.app.command} personal messages download "Book club" 101 --output-dir ./downloads`],
|
|
22
|
+
effects: FETCH,
|
|
23
|
+
})
|
|
21
24
|
.description("save a message's photos, files, videos and voice notes to a folder — or a whole chat's with --all")
|
|
22
25
|
.argument("<chat>", messenger.chatArgument)
|
|
23
26
|
.argument("[message]", "the message id; left out with --all")
|
|
@@ -7,6 +7,7 @@ type Need = readonly [method: keyof MessengerAdapter, what: string];
|
|
|
7
7
|
*/
|
|
8
8
|
export declare const COMMAND_NEEDS: Readonly<Record<string, Need>>;
|
|
9
9
|
interface Lacking {
|
|
10
|
+
channelComments?: boolean;
|
|
10
11
|
lacks?: readonly (keyof MessengerAdapter)[];
|
|
11
12
|
history?: "server" | "store";
|
|
12
13
|
}
|