@wirecat/cli-core 0.391.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 +29 -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/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/store-maintenance-command.d.ts +1 -1
- 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 +1 -1
- package/parity.json +339 -107
package/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,16 @@ 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.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.
|
|
9
18
|
|
|
10
19
|
## 0.391.0 — 23.10.2026
|
|
11
20
|
|
|
@@ -18,6 +27,18 @@ shape of this file.
|
|
|
18
27
|
|
|
19
28
|
### Added
|
|
20
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
|
+
|
|
21
42
|
- `readMeetingReview` (`@wirecat/cli-core/services`) and `MeetingStore.meetingReview`: the newest meetings of a
|
|
22
43
|
selection, each with its newest summary (overview, next steps), transcript lines and words, the three who talked
|
|
23
44
|
longest and its confirmed projects. No transcript text is read out.
|
|
@@ -37,6 +58,13 @@ shape of this file.
|
|
|
37
58
|
forms. Unlike Lucene, order costs nothing: `"a b"~0` finds "b a" too. `word~1` stays an error. A saved search may
|
|
38
59
|
now hold `~N`. `QUERY_OPERATORS` splits the `fuzzy / proximity` row into `proximity` and `fuzzy`.
|
|
39
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
|
+
|
|
40
68
|
## 0.389.0 — 22.10.2026
|
|
41
69
|
|
|
42
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/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`)
|
|
@@ -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
|
}
|
|
@@ -19,6 +19,7 @@ export const COMMAND_NEEDS = {
|
|
|
19
19
|
"contacts remove": ["removeContact", "remove a contact"],
|
|
20
20
|
"contacts rename": ["renameContact", "rename a contact"],
|
|
21
21
|
"contacts import": ["importContacts", "import contacts"],
|
|
22
|
+
"messages comments": ["comments", "read comments on a channel post"],
|
|
22
23
|
"messages pin": ["pin", "pin a message"],
|
|
23
24
|
"messages unpin": ["unpin", "unpin a message"],
|
|
24
25
|
};
|
|
@@ -27,7 +28,11 @@ const STORE_ANSWERS = new Set(["chats events"]);
|
|
|
27
28
|
/** The one method this command needs and this messenger never has; none when it has it or the store answers. */
|
|
28
29
|
export const lackedNeed = (messenger, path) => {
|
|
29
30
|
const need = COMMAND_NEEDS[path];
|
|
30
|
-
if (!need
|
|
31
|
+
if (!need)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (path === "messages comments" && messenger?.channelComments === false)
|
|
34
|
+
return need;
|
|
35
|
+
if (!messenger?.lacks?.includes(need[0]))
|
|
31
36
|
return undefined;
|
|
32
37
|
return messenger.history === "store" && STORE_ANSWERS.has(path) ? undefined : need;
|
|
33
38
|
};
|
|
@@ -6,6 +6,7 @@ import { annotate } from "@wirecat/cli-base/commands";
|
|
|
6
6
|
import { installerOf } from "@wirecat/cli-base/update";
|
|
7
7
|
import { Command } from "commander";
|
|
8
8
|
import { ATTACHMENT_LIMIT_ENV } from "../../attachments/limits.js";
|
|
9
|
+
import { portOf, publicUrlOf } from "../../http/public-url.js";
|
|
9
10
|
import { envName } from "../../ports/app.js";
|
|
10
11
|
import { environmentOf } from "../context.js";
|
|
11
12
|
import { grantsAuthority, LOCAL_READ, LOCAL_WRITE, REMOTE_WRITE } from "../effects.js";
|
|
@@ -17,32 +18,6 @@ import { messengerContext } from "./context.js";
|
|
|
17
18
|
const DEFAULT_PORT = 8765;
|
|
18
19
|
/** Where `mcp --http` keeps the hashes of the tokens it issued — per profile, in the state folder. */
|
|
19
20
|
export const httpTokenFile = (app, profile, env) => join(resolvePaths({ appName: app.appName, prefix: app.envPrefix, env }).state, "mcp-http", `${profile}.json`);
|
|
20
|
-
const publicUrlOf = (app, given) => {
|
|
21
|
-
const example = `--public-url https://<name>.ts.net`;
|
|
22
|
-
if (!given)
|
|
23
|
-
throw new CliError("configuration_error", `--http needs the tunnel's address: ${example}`);
|
|
24
|
-
let url;
|
|
25
|
-
try {
|
|
26
|
-
url = new URL(given);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
throw new CliError("validation_error", `--public-url is not an address: ${example}`);
|
|
30
|
-
}
|
|
31
|
-
const local = url.protocol === "http:" && ["localhost", "127.0.0.1"].includes(url.hostname);
|
|
32
|
-
if (url.protocol !== "https:" && !local)
|
|
33
|
-
throw new CliError("validation_error", `--public-url must be https — the browser apps reach ${app.command} through it`);
|
|
34
|
-
if (url.pathname !== "/" || url.search || url.hash)
|
|
35
|
-
throw new CliError("validation_error", `--public-url is the tunnel's address only, without a path: ${example}`);
|
|
36
|
-
return url;
|
|
37
|
-
};
|
|
38
|
-
const portOf = (given) => {
|
|
39
|
-
if (given === undefined)
|
|
40
|
-
return DEFAULT_PORT;
|
|
41
|
-
const port = Number(given);
|
|
42
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
43
|
-
throw new CliError("validation_error", "--port takes 1–65535");
|
|
44
|
-
return port;
|
|
45
|
-
};
|
|
46
21
|
const withFlags = (command) => command.option("--permission <key=level>", "make a permission stricter for this server only, like messages.send=deny; it never loosens the profile; repeat for more keys", (value, previous = []) => [...previous, value]);
|
|
47
22
|
export const mcpCommand = (messenger) => {
|
|
48
23
|
const { app } = messenger;
|
|
@@ -61,11 +36,14 @@ export const mcpCommand = (messenger) => {
|
|
|
61
36
|
return;
|
|
62
37
|
}
|
|
63
38
|
if (flags.http) {
|
|
64
|
-
const publicUrl = publicUrlOf(
|
|
39
|
+
const publicUrl = publicUrlOf(flags.publicUrl, {
|
|
40
|
+
missing: "--http",
|
|
41
|
+
reaches: `the browser apps reach ${app.command}`,
|
|
42
|
+
});
|
|
65
43
|
const { serveOverHttpUntilStopped } = await import("../../mcp/server.js");
|
|
66
44
|
await serveOverHttpUntilStopped(this, context, messenger, {}, {
|
|
67
45
|
publicUrl,
|
|
68
|
-
port: portOf(flags.port),
|
|
46
|
+
port: portOf(flags.port, DEFAULT_PORT),
|
|
69
47
|
tokenFile: httpTokenFile(app, context.settings.profile, context.env),
|
|
70
48
|
});
|
|
71
49
|
return;
|
|
@@ -36,7 +36,10 @@ export const messagesCommand = (messenger) => {
|
|
|
36
36
|
const messages = new Command("messages").description("read and send messages");
|
|
37
37
|
messages.addCommand(evidenceCommand(messenger));
|
|
38
38
|
const history = historyQuery(messenger);
|
|
39
|
-
const list = annotate(messages.command("list"), {
|
|
39
|
+
const list = annotate(messages.command("list"), {
|
|
40
|
+
examples: [`${messenger.app.command} personal messages list "Book club" --limit 20`],
|
|
41
|
+
effects: REMOTE_READ,
|
|
42
|
+
})
|
|
40
43
|
.description("a chat's messages, oldest to newest")
|
|
41
44
|
.addHelpText("after", SAVE_HELP)
|
|
42
45
|
.addHelpText("after", queryHelp(history))
|
|
@@ -105,7 +108,10 @@ export const messagesCommand = (messenger) => {
|
|
|
105
108
|
}), view);
|
|
106
109
|
};
|
|
107
110
|
messages.addCommand(sendCommand(messenger));
|
|
108
|
-
annotate(messages.command("show"), {
|
|
111
|
+
annotate(messages.command("show"), {
|
|
112
|
+
examples: [`${messenger.app.command} personal messages show "Book club" 101`],
|
|
113
|
+
effects: REMOTE_READ,
|
|
114
|
+
})
|
|
109
115
|
.description("one message, by its chat and id or by its msg: locator")
|
|
110
116
|
.argument("<chat>", `${messenger.chatArgument}; or a msg: locator, with no message id after it`)
|
|
111
117
|
.argument("[message]", "the message id")
|
|
@@ -114,7 +120,10 @@ export const messagesCommand = (messenger) => {
|
|
|
114
120
|
return await readMailWindow(this, chat, { before: 0, after: 0 });
|
|
115
121
|
await readWindow(this, chat, message, { before: 0, after: 0 });
|
|
116
122
|
});
|
|
117
|
-
threadOptions(annotate(messages.command("context"), {
|
|
123
|
+
threadOptions(annotate(messages.command("context"), {
|
|
124
|
+
examples: [`${messenger.app.command} personal messages context "Book club" 101 --before-n 3 --after-n 3`],
|
|
125
|
+
effects: REMOTE_READ,
|
|
126
|
+
}))
|
|
118
127
|
.description("a message and what came either side of it, oldest first")
|
|
119
128
|
.addHelpText("after", SAVE_HELP)
|
|
120
129
|
.argument("<chat>", `${messenger.chatArgument}; or a msg: locator, with no message id after it`)
|
|
@@ -10,7 +10,10 @@ import { messengerContext } from "./context.js";
|
|
|
10
10
|
import { COMMENTS_QUERY, cursorOf, historyWindow } from "./history-query.js";
|
|
11
11
|
import { messageItems, viewOf } from "./message-items.js";
|
|
12
12
|
export const commentsCommand = (messenger) => {
|
|
13
|
-
const command = annotate(new Command("comments"), {
|
|
13
|
+
const command = annotate(new Command("comments"), {
|
|
14
|
+
examples: [`${messenger.app.command} personal messages comments "News channel" 101 --limit 20`],
|
|
15
|
+
effects: REMOTE_READ,
|
|
16
|
+
})
|
|
14
17
|
.description("the comments under a channel post, oldest to newest; they live in its discussion group")
|
|
15
18
|
.addHelpText("after", queryHelp(COMMENTS_QUERY))
|
|
16
19
|
.argument("<chat>", `the channel: ${messenger.chatArgument}`)
|