@wirecat/cli-core 0.530.0 → 0.532.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 +48 -0
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/list-query.d.ts +0 -4
- package/dist/cli/list-query.js +19 -1
- package/dist/cli/messenger/attachments-command.d.ts +1 -0
- package/dist/cli/messenger/attachments-command.js +1 -1
- package/dist/cli/messenger/download-command.js +37 -10
- package/dist/cli/messenger/schedule-command.d.ts +2 -19
- package/dist/cli/messenger/schedule-command.js +2 -231
- package/dist/cli/messenger/search-answers.js +7 -2
- package/dist/cli/messenger/topics-command.js +22 -5
- package/dist/cli/schedule/schedule-command.d.ts +19 -0
- package/dist/cli/schedule/schedule-command.js +261 -0
- package/dist/cli/shared-command-exports.d.ts +1 -1
- package/dist/cli/shared-command-exports.js +2 -1
- package/dist/domain/attachments.d.ts +9 -1
- package/dist/domain/attachments.js +12 -0
- package/dist/domain/message-link.d.ts +14 -0
- package/dist/domain/message-link.js +8 -0
- package/dist/domain/models.d.ts +2 -0
- package/dist/entities/attachment.d.ts +7 -0
- package/dist/entities/attachment.js +2 -0
- package/dist/entities/hit.d.ts +7 -0
- package/dist/entities/hit.js +3 -0
- package/dist/entities/index.d.ts +61 -2
- package/dist/entities/index.js +6 -3
- package/dist/entities/list-results.d.ts +3 -0
- package/dist/entities/list-results.js +1 -0
- package/dist/entities/message-link.d.ts +8 -0
- package/dist/entities/message-link.js +9 -1
- package/dist/entities/task-results.d.ts +30 -0
- package/dist/entities/task-results.js +1 -0
- package/dist/entities/task.d.ts +7 -0
- package/dist/entities/task.js +2 -0
- package/dist/errors/reasons.js +1 -0
- package/dist/mail/account-key.d.ts +2 -0
- package/dist/mail/account-key.js +1 -0
- package/dist/mail/cli/index.js +10 -10
- package/dist/mail/index.d.ts +1 -0
- package/dist/mail/index.js +1 -0
- package/dist/mail/retry.js +2 -1
- package/dist/mail/rule-stored.js +2 -1
- package/dist/mail/sender-scores.js +2 -1
- package/dist/mail/sync.js +3 -2
- package/dist/ports/messenger.d.ts +3 -0
- package/dist/services/attachments.d.ts +11 -0
- package/dist/services/attachments.js +45 -2
- package/dist/services/deps.d.ts +1 -1
- package/dist/services/deps.js +1 -1
- package/dist/services/messages.d.ts +2 -3
- package/dist/services/messages.js +27 -8
- package/dist/services/search-all.d.ts +3 -0
- package/dist/services/search-all.js +2 -0
- package/dist/services/tasks.d.ts +4 -2
- package/dist/services/tasks.js +2 -2
- package/dist/services/topics.d.ts +3 -0
- package/dist/services/topics.js +20 -6
- package/dist/store/index.d.ts +1 -0
- package/dist/store/sqlite/task-board.js +3 -1
- package/dist/store/sqlite/task-comments.d.ts +23 -0
- package/dist/store/sqlite/task-comments.js +41 -0
- package/dist/store/sqlite/things.js +8 -1
- package/dist/store/sqlite/work-tasks.d.ts +13 -2
- package/dist/store/sqlite/work-tasks.js +52 -1
- package/package.json +1 -1
- package/parity.json +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,54 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.532.0 — 01.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A message hit names its files**: `search messages` and `search all` hits carry
|
|
15
|
+
`attachments: [{ position, kind, name, size }]` (files only, absent when there are none; `name` and `size` when the
|
|
16
|
+
store has them). `position` counts every attachment from 1, as `attachments show --attachment` takes it.
|
|
17
|
+
`attachments.name` is listed in the hit's `untrusted` fields.
|
|
18
|
+
- **`messages download --offline`** copies files the local store already holds, by chat and id or by a `msg:`
|
|
19
|
+
locator, and never connects; `--attachment <n>` picks one. A file not saved here fails with `not_found` before
|
|
20
|
+
anything is written, and the store keeps pointing at its own copy.
|
|
21
|
+
- **`messages download` takes a `msg:` locator** in place of chat and id; the chat and id form still works.
|
|
22
|
+
- **`messages link` lists a message's files**: `files: [{ position, kind, name, url, reason }]`. No messenger gives
|
|
23
|
+
a file its own link yet, so `url` is null with reason `unsupported_provider`.
|
|
24
|
+
|
|
25
|
+
## 0.531.0 — 01.11.2026
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`topics create --emoji` and `topics edit --emoji`: a forum topic's icon.** The value is an emoji from the
|
|
30
|
+
messenger's topic icon set, which `topics icons list` shows; `topics edit --emoji none` removes the icon. An emoji
|
|
31
|
+
change is journaled as `topic-edit`. Adapters take it as `createTopic(…, { icon })` and `TopicChange.icon`
|
|
32
|
+
(`null` removes it), and list the set with the optional `TopicEditing.topicIcons()`; the `topic_icon` entity is the
|
|
33
|
+
list's item.
|
|
34
|
+
- **`tasks.edit(id, { project })` moves a project task to another of the owner's projects.** It takes the next key
|
|
35
|
+
there; `id`, `ref` and `key` change, and the old key still finds the task through a `project` event in its
|
|
36
|
+
history. The source project's numbers are never reused. A subtask leaves its parent; a
|
|
37
|
+
task with subtasks is refused (`task.has-subtasks`, with their keys). The revision goes up.
|
|
38
|
+
- **`tasks.edit(id, { type })` changes a project task's kind** among bug, feature and chore, as a `type` event, and
|
|
39
|
+
bumps the revision.
|
|
40
|
+
- **A task names who added it**: `TaskDetails.createdBy` (`created_by` in the `task` and `task_card` entities),
|
|
41
|
+
`{ type, id, name, owner }`. `tasksService.add` takes an `author`, so a task found in a message by a named agent is
|
|
42
|
+
that agent's, not `agent`'s.
|
|
43
|
+
- **Task comments**: `tasks.comment(id, text, by)` keeps a note about the task in its author's name; `tasks.comments`
|
|
44
|
+
lists them oldest first with `author`, `text` and `at`. A comment leaves the revision as it is.
|
|
45
|
+
- **`tomorrow` in any time field of a list query** (`due:tomorrow`, `due:[today TO tomorrow]`), as the owner's next
|
|
46
|
+
calendar day: `listQuery` reads it before validating. `today` and `yesterday` already worked.
|
|
47
|
+
- **`mailAccountKey(address)`** from `./mail`: the store key of a mail account, `{ provider: "email", account }` with
|
|
48
|
+
the address in lower case. **`positiveCount(flag)`** from `./cli`: the parser of a whole-number flag from 1 up.
|
|
49
|
+
- **The shared `schedule` command lives in `src/cli/schedule/`**, not under the messenger commands. `scheduleCommandFor`
|
|
50
|
+
and `ScheduleHost` come from `./cli` as before; the old module path re-exports them. Each `schedule` command now
|
|
51
|
+
has examples and described arguments, and the example profile of the shared mail read commands is `personal`.
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- **`schedule command` and `schedule agent` refuse a name that another profile already uses**, rather than replacing
|
|
56
|
+
its schedule and timer. Schedules of the same profile are still replaced. Existing timers keep their names.
|
|
57
|
+
|
|
10
58
|
## 0.530.0 — 01.11.2026
|
|
11
59
|
|
|
12
60
|
### Added
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -97,7 +97,6 @@ export { httpTokenFile, type McpEnvironment, type McpHost, serverEntry } from ".
|
|
|
97
97
|
export { type MessageItem, messageItem, type ReadMessage } from "./messenger/message-items.js";
|
|
98
98
|
export { type ModeProblem, type PrivateFiles, privateFiles, withSqliteSidecars } from "./messenger/private-files.js";
|
|
99
99
|
export { buildReport as buildDoctorReport, type Report as DoctorReport, type ReportContext as DoctorReportContext, reportFileName as doctorReportFileName, } from "./messenger/report.js";
|
|
100
|
-
export type { ScheduleHost } from "./messenger/schedule-command.js";
|
|
101
100
|
export { allAnswer, allHitItem, coverageGaps, type HitItem, type MessageHitItem, messageHitItem, type NoteHitItem, noteHitItem, shownOf, } from "./messenger/search-answers.js";
|
|
102
101
|
export { SEARCHES_QUERY } from "./messenger/searches-command.js";
|
|
103
102
|
export { servingProfiles } from "./messenger/serve-command.js";
|
|
@@ -108,12 +107,13 @@ export { storeSummary } from "./messenger/store-maintenance-command.js";
|
|
|
108
107
|
export { type Saving, stored } from "./messenger/stored.js";
|
|
109
108
|
export { dateOrNone } from "./messenger/tasks-command.js";
|
|
110
109
|
export { MODEL_CATALOGUE_QUERY, type ModelCatalogueOptions } from "./model-catalogue-command.js";
|
|
111
|
-
export { window, withPaging } from "./paging.js";
|
|
110
|
+
export { positiveCount, window, withPaging } from "./paging.js";
|
|
112
111
|
export { type CommandPermissions, commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, type PermissionKeyOf, type PermissionKeySource, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
|
|
113
112
|
export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
|
|
114
113
|
export { createProgram, type ProgramDefinition, type ProgramOptions, type RunOptions, run } from "./program.js";
|
|
115
114
|
export { type ProjectTextHints, projectAnswerParts, projectCutoffTime, projectEvidenceText, projectWindowOf, withSyncCommands, } from "./project-evidence.js";
|
|
116
115
|
export { fieldsOf, projectFields } from "./result-fields.js";
|
|
116
|
+
export type { ScheduleHost } from "./schedule/schedule-command.js";
|
|
117
117
|
export { type Config, type Configuration, count, first, flag, fromFile, type GlobalFlags, NONE_NAMED, namedProfile, type ProfileKind, plain, type ResolveOptions, type Settings, type SettingsExtension, type SettingsExtensionContext, type Source, settingsFor, } from "./settings.js";
|
|
118
118
|
export { type PermissionAnswer, readSetupAnswers, type SetupAnswers, setupPermissionCeiling } from "./setup-answers.js";
|
|
119
119
|
export { AGENTS, type Agent, agentFor, screenFor } from "./setup-screen.js";
|
package/dist/cli/index.js
CHANGED
|
@@ -97,7 +97,7 @@ export { storeSummary } from "./messenger/store-maintenance-command.js";
|
|
|
97
97
|
export { stored } from "./messenger/stored.js";
|
|
98
98
|
export { dateOrNone } from "./messenger/tasks-command.js";
|
|
99
99
|
export { MODEL_CATALOGUE_QUERY } from "./model-catalogue-command.js";
|
|
100
|
-
export { window, withPaging } from "./paging.js";
|
|
100
|
+
export { positiveCount, window, withPaging } from "./paging.js";
|
|
101
101
|
export { commandPathKey, commandPermissions, knownBeside, knownPermissionKeys, refuseUnknownPermissionKeys, unknownPermissionIssues, unknownPermissionKeys, } from "./permission-keys.js";
|
|
102
102
|
export { asFirstWord, commandWords, liftProfile, profileFree, refuseCommandName, rootOf, takesProfile, usableProfileName, } from "./profile.js";
|
|
103
103
|
export { createProgram, run } from "./program.js";
|
package/dist/cli/list-query.d.ts
CHANGED
|
@@ -16,10 +16,6 @@ export declare const withQuery: (command: Command, registry: QueryRegistry) => C
|
|
|
16
16
|
export declare const unfilteredQuery: (entity: string, schema: Parameters<typeof queryFieldsOf>[0]) => QueryRegistry;
|
|
17
17
|
/** `tasks list`, as a query error names the command. */
|
|
18
18
|
export declare const commandPath: (command: Command) => string;
|
|
19
|
-
/**
|
|
20
|
-
* The query as typed, validated against the list's fields, with each default whose field it does not name;
|
|
21
|
-
* `undefined` when nothing narrows the list. A bare word is refused unless the list names a `defaultField` for it. CLI and MCP read a list's query through this one function.
|
|
22
|
-
*/
|
|
23
19
|
export declare const listQuery: (text: string, registry: QueryRegistry, command: string, { defaultField }?: {
|
|
24
20
|
defaultField?: string;
|
|
25
21
|
}) => QueryAst | undefined;
|
package/dist/cli/list-query.js
CHANGED
|
@@ -39,9 +39,27 @@ export const commandPath = (command) => {
|
|
|
39
39
|
* The query as typed, validated against the list's fields, with each default whose field it does not name;
|
|
40
40
|
* `undefined` when nothing narrows the list. A bare word is refused unless the list names a `defaultField` for it. CLI and MCP read a list's query through this one function.
|
|
41
41
|
*/
|
|
42
|
+
/**
|
|
43
|
+
* `tomorrow` in a time field as the owner's next calendar date: the query language knows `today` and `yesterday`
|
|
44
|
+
* only, and a due date is more often ahead than behind.
|
|
45
|
+
*/
|
|
46
|
+
const withTomorrow = (query, registry, zone = timezoneOf(), now = Date.now()) => {
|
|
47
|
+
const times = new Set(registry.fields.filter(({ type }) => type === "time").map(({ name }) => name));
|
|
48
|
+
const date = new Date(`${new Intl.DateTimeFormat("en-CA", { timeZone: zone }).format(now)}T00:00:00Z`);
|
|
49
|
+
date.setUTCDate(date.getUTCDate() + 1);
|
|
50
|
+
const tomorrow = date.toISOString().slice(0, 10);
|
|
51
|
+
const day = (value) => (value.toLowerCase() === "tomorrow" ? tomorrow : value);
|
|
52
|
+
const map = (node) => node.kind === "predicate"
|
|
53
|
+
? times.has(node.field)
|
|
54
|
+
? { ...node, value: day(node.value), ...(node.upper === undefined ? {} : { upper: day(node.upper) }) }
|
|
55
|
+
: node
|
|
56
|
+
: { ...node, clauses: node.clauses.map((clause) => ({ ...clause, node: map(clause.node) })) };
|
|
57
|
+
return { ...query, root: map(query.root) };
|
|
58
|
+
};
|
|
42
59
|
export const listQuery = (text, registry, command, { defaultField = "none" } = {}) => {
|
|
43
60
|
const context = { command, guide: LIST_QUERY_GUIDE };
|
|
44
|
-
const
|
|
61
|
+
const parsed = text.trim() === "" ? undefined : parseLucene(text, { defaultField, ...context });
|
|
62
|
+
const typed = parsed && withTomorrow(parsed, registry);
|
|
45
63
|
if (typed)
|
|
46
64
|
validateQuery(typed, registry, context);
|
|
47
65
|
const named = typed ? fieldsNamed(typed) : new Set();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { type Messenger } from "./context.js";
|
|
3
|
+
export declare const count: (value: string) => number;
|
|
3
4
|
/** Files of stored messages: what is in them, for `content:` in a search. Nothing here is sent. */
|
|
4
5
|
export declare const attachmentsCommand: (messenger: Messenger) => Command;
|
|
@@ -18,7 +18,7 @@ import { messengerContext, refuseLocalWrite } from "./context.js";
|
|
|
18
18
|
import { addMediaArchiveCommands } from "./media-archive-command.js";
|
|
19
19
|
import { stopOnSignal } from "./patience.js";
|
|
20
20
|
import { readAll } from "./stdin.js";
|
|
21
|
-
const count = (value) => {
|
|
21
|
+
export const count = (value) => {
|
|
22
22
|
const parsed = Number(value);
|
|
23
23
|
if (!Number.isInteger(parsed) || parsed < 1)
|
|
24
24
|
throw new CliError("validation_error", `"${value}" is not a count`);
|
|
@@ -4,6 +4,8 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
4
4
|
import { CliError, writeSecurely } from "@wirecat/cli-base";
|
|
5
5
|
import { annotate } from "@wirecat/cli-base/commands";
|
|
6
6
|
import { NOT_FILES } from "../../domain/attachments.js";
|
|
7
|
+
import { isLocator } from "../../domain/locator.js";
|
|
8
|
+
import { messageLinkTarget } from "../../domain/message-link.js";
|
|
7
9
|
import { FETCHING, keyOf } from "../../services/archive.js";
|
|
8
10
|
import { batchProgress, mergeBatches } from "../../services/batch.js";
|
|
9
11
|
import { saveFiles } from "../../services/file-download.js";
|
|
@@ -15,25 +17,45 @@ import { patiently } from "../../services/patience.js";
|
|
|
15
17
|
import { FETCH } from "../effects.js";
|
|
16
18
|
import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
17
19
|
import { nextStep } from "../steps.js";
|
|
20
|
+
import { count } from "./attachments-command.js";
|
|
18
21
|
import { messengerContext, refuseLocalWrite } from "./context.js";
|
|
19
22
|
import { stopOnSignal } from "./patience.js";
|
|
20
23
|
/** `messages download`: every file of one message, or with `--all` of a whole chat, never over a file already there. */
|
|
21
24
|
export const downloadSubcommand = (messages, messenger) => annotate(messages.command("download"), {
|
|
22
25
|
output: { entity: "message_download" },
|
|
23
|
-
examples: [
|
|
26
|
+
examples: [
|
|
27
|
+
`${messenger.app.command} personal messages download "Book club" 101 --output-dir ./downloads`,
|
|
28
|
+
`${messenger.app.command} personal messages download msg:${messenger.provider}/me/500/101 --attachment 1 --offline`,
|
|
29
|
+
],
|
|
24
30
|
effects: FETCH,
|
|
25
31
|
})
|
|
26
32
|
.summary("download files attached to stored messages")
|
|
27
|
-
.description("save a message's photos, files, videos and voice notes to a folder — or a whole chat's with --all"
|
|
28
|
-
|
|
29
|
-
.argument("
|
|
33
|
+
.description("save a message's photos, files, videos and voice notes to a folder — or a whole chat's with --all; " +
|
|
34
|
+
"with --offline, copy the ones this computer already holds, one by --attachment")
|
|
35
|
+
.argument("<chat>", `${messenger.chatArgument}; or a msg: locator, with no message id after it`)
|
|
36
|
+
.argument("[message]", "the message id; left out with --all or a locator")
|
|
30
37
|
.option("--output-dir <dir>", "where to save them; created if missing", ".")
|
|
38
|
+
.option("--attachment <n>", "which file of the message, from 1; needed when it has more than one", count)
|
|
31
39
|
.option("--all", "every file of the chat, newest first; run it again to continue where it stopped")
|
|
32
40
|
.option("--pause <duration>", "with --all, a pause between pages, to stay under the provider's limits", messenger.fetching?.pause ?? FETCHING.pause)
|
|
33
41
|
.option("--extract", "read text layers from the files this download maps into the local content index")
|
|
34
42
|
.action(async function (chat, messageId) {
|
|
35
43
|
const context = messengerContext(this, messenger);
|
|
36
|
-
const { outputDir: output, all, pause, extract, } = this.opts();
|
|
44
|
+
const { outputDir: output, all, pause, extract, attachment, } = this.opts();
|
|
45
|
+
if (context.settings.offline) {
|
|
46
|
+
if (all || extract)
|
|
47
|
+
throw new CliError("validation_error", "--offline copies one message's saved files; leave out --all and --extract");
|
|
48
|
+
const saved = await context.withServices((services) => services.attachments.saveHeld({
|
|
49
|
+
chat,
|
|
50
|
+
...(messageId === undefined ? {} : { message: messageId.trim() }),
|
|
51
|
+
...(attachment === undefined ? {} : { attachment }),
|
|
52
|
+
output,
|
|
53
|
+
}));
|
|
54
|
+
renderAnswer(this, context, itemOf(snakeKeys({ files: saved })), () => `${saved.map((one) => one.path).join("\n")}\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (attachment !== undefined)
|
|
58
|
+
throw new CliError("validation_error", "--attachment picks a file this computer already holds; add --offline");
|
|
37
59
|
if (extract)
|
|
38
60
|
refuseLocalWrite(context, messenger.app.command, "attachments.extract");
|
|
39
61
|
if (all && messageId !== undefined) {
|
|
@@ -43,18 +65,19 @@ export const downloadSubcommand = (messages, messenger) => annotate(messages.com
|
|
|
43
65
|
await downloadChat(this, context, messenger, chat, output, parseDuration(pause, "--pause"), extract);
|
|
44
66
|
return;
|
|
45
67
|
}
|
|
46
|
-
if (messageId === undefined)
|
|
68
|
+
if (messageId === undefined && !isLocator(chat))
|
|
47
69
|
throw new CliError("validation_error", "name a message id, or use --all");
|
|
48
|
-
const
|
|
70
|
+
const target = messageLinkTarget(chat, messageId?.trim(), messenger.provider);
|
|
71
|
+
const id = target.message;
|
|
49
72
|
const result = await context.withServices(async (services) => {
|
|
50
|
-
const { files, skipped } = await services.messages.download(chat,
|
|
73
|
+
const { files, skipped } = await services.messages.download(chat, messageId?.trim());
|
|
51
74
|
if (skipped.length > 0)
|
|
52
75
|
context.renderer.note(`not a file, not downloaded: ${skipped.join(", ")}`);
|
|
53
76
|
if (files.length === 0)
|
|
54
77
|
throw new CliError("not_found", `message ${id} has no file to download`);
|
|
55
78
|
mkdirSync(output, { recursive: true });
|
|
56
79
|
const progress = batchProgress(context.env);
|
|
57
|
-
const downloaded = await saveFiles(services.messages, chat, id, files, output, {
|
|
80
|
+
const downloaded = await saveFiles(services.messages, target.chat, id, files, output, {
|
|
58
81
|
warn: context.renderer.warn,
|
|
59
82
|
batch: progress,
|
|
60
83
|
});
|
|
@@ -62,7 +85,11 @@ export const downloadSubcommand = (messages, messenger) => annotate(messages.com
|
|
|
62
85
|
let extraction;
|
|
63
86
|
if (extract) {
|
|
64
87
|
try {
|
|
65
|
-
extraction = await services.attachments.extract({
|
|
88
|
+
extraction = await services.attachments.extract({
|
|
89
|
+
chat: target.chat,
|
|
90
|
+
message: id,
|
|
91
|
+
paths: done.map((file) => file.path),
|
|
92
|
+
});
|
|
66
93
|
}
|
|
67
94
|
catch (error) {
|
|
68
95
|
const issue = progress.fail(id, "extract", error);
|
|
@@ -1,21 +1,4 @@
|
|
|
1
|
-
import { Command } from "commander";
|
|
2
|
-
import { type AppIdentity } from "../../ports/app.js";
|
|
3
|
-
import { type AnswerOutput } from "../envelope.js";
|
|
1
|
+
import type { Command } from "commander";
|
|
4
2
|
import { type Messenger } from "./context.js";
|
|
5
|
-
|
|
6
|
-
export interface ScheduleHost {
|
|
7
|
-
app: AppIdentity;
|
|
8
|
-
context(command: Command, resolving?: {
|
|
9
|
-
needsProfile?: boolean;
|
|
10
|
-
}): AnswerOutput & {
|
|
11
|
-
env: NodeJS.ProcessEnv;
|
|
12
|
-
profile: string;
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
/**
|
|
16
|
-
* Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
|
|
17
|
-
* (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
|
|
18
|
-
* the rest, so what runs and with which variables is the same on all three.
|
|
19
|
-
*/
|
|
20
|
-
export declare const scheduleCommandFor: ({ app, context: contextOf }: ScheduleHost) => Command;
|
|
3
|
+
export { type ScheduleHost, scheduleCommandFor } from "../schedule/schedule-command.js";
|
|
21
4
|
export declare const scheduleCommand: (messenger: Messenger) => Command;
|
|
@@ -1,235 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { CliError, resolvePaths, writeSecurely } from "@wirecat/cli-base";
|
|
4
|
-
import { annotate } from "@wirecat/cli-base/commands";
|
|
5
|
-
import { Argument, Command, Option } from "commander";
|
|
6
|
-
import { describeWhen, findProgram, lastRun, listSchedules, runSchedule, scheduleFile, scheduleName, timerFor, } from "../../background/schedules.js";
|
|
7
|
-
import { thisMachine } from "../../background/system.js";
|
|
8
|
-
import { locationVariables } from "../../background/units.js";
|
|
9
|
-
import { parseDuration } from "../../domain/duration.js";
|
|
10
|
-
import { readOnlyLockOf } from "../../ports/app.js";
|
|
11
|
-
import { aheadOf } from "../../services/moment.js";
|
|
12
|
-
import { storePath } from "../../store/path.js";
|
|
13
|
-
import { environmentOf, setExitCode } from "../context.js";
|
|
14
|
-
import { grantsAuthority, LOCAL_READ, LOCAL_WRITE, REMOTE_WRITE } from "../effects.js";
|
|
15
|
-
import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
16
|
-
import { withQuery } from "../list-query.js";
|
|
17
|
-
import { lineOf } from "../steps.js";
|
|
1
|
+
import { scheduleCommandFor } from "../schedule/schedule-command.js";
|
|
18
2
|
import { messengerContext } from "./context.js";
|
|
19
|
-
|
|
20
|
-
/** Each run of an agent is a paid model call that reads chats; keeping the store current is `serve`'s job. */
|
|
21
|
-
const AGENT_MINIMUM_MINUTES = 60;
|
|
22
|
-
const COMMAND_MINIMUM_MINUTES = 1;
|
|
23
|
-
const HARNESSES = ["claude", "codex"];
|
|
24
|
-
const whenOf = ({ every, at, atTime }, minimum) => {
|
|
25
|
-
if ([every ?? at, atTime].filter(Boolean).length !== 1) {
|
|
26
|
-
throw new CliError("validation_error", "say when: --every <30m|2h|1d>, --at HH:MM (daily), or --at-time <30m|2d|ISO time>");
|
|
27
|
-
}
|
|
28
|
-
if (atTime) {
|
|
29
|
-
const time = aheadOf(atTime, "--at-time");
|
|
30
|
-
if (time <= Date.now())
|
|
31
|
-
throw new CliError("validation_error", "--at-time is in the past");
|
|
32
|
-
return { once: new Date(time).toISOString() };
|
|
33
|
-
}
|
|
34
|
-
if (at) {
|
|
35
|
-
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(at))
|
|
36
|
-
throw new CliError("validation_error", `--at takes HH:MM, not "${at}"`);
|
|
37
|
-
if (every && parseDuration(every, "--every") !== 86_400_000) {
|
|
38
|
-
throw new CliError("validation_error", "--at runs once a day; leave out --every, or use --every 1d");
|
|
39
|
-
}
|
|
40
|
-
return { daily: at };
|
|
41
|
-
}
|
|
42
|
-
const minutes = parseDuration(every ?? "", "--every") / 60_000;
|
|
43
|
-
if (!Number.isInteger(minutes) || minutes < minimum) {
|
|
44
|
-
throw new CliError("validation_error", `--every is at least ${minimum} minute${minimum === 1 ? "" : "s"} here, in whole minutes`);
|
|
45
|
-
}
|
|
46
|
-
return { every: minutes };
|
|
47
|
-
};
|
|
48
|
-
/** A schedule as answered: snake_case, but its environment keeps the variables' own names. */
|
|
49
|
-
const scheduleAnswer = ({ environment, createdAt, ...schedule }, extra) => ({
|
|
50
|
-
...snakeKeys(schedule),
|
|
51
|
-
environment,
|
|
52
|
-
created_at: createdAt,
|
|
53
|
-
...snakeKeys(extra),
|
|
54
|
-
});
|
|
55
|
-
const withWhen = (command) => command
|
|
56
|
-
.option("--every <duration>", "repeat: 30m, 2h, 1d")
|
|
57
|
-
.option("--at <HH:MM>", "every day at this local time")
|
|
58
|
-
.option("--at-time <time>", "one run: 30m, 2d from now, or an ISO time; the schedule removes itself after it");
|
|
59
|
-
/**
|
|
60
|
-
* Runs the CLI's own commands, or an agent, later or on a timer — through systemd (Linux), launchd
|
|
61
|
-
* (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
|
|
62
|
-
* the rest, so what runs and with which variables is the same on all three.
|
|
63
|
-
*/
|
|
64
|
-
export const scheduleCommandFor = ({ app, context: contextOf }) => {
|
|
65
|
-
const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
|
|
66
|
-
const prepare = (self, resolving = {}) => {
|
|
67
|
-
const context = contextOf(self, resolving);
|
|
68
|
-
const system = environmentOf(self).system ?? thisMachine();
|
|
69
|
-
const state = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env }).state;
|
|
70
|
-
const say = (value, lines) => renderAnswer(self, context, itemOf(value), () => lines.join("\n"));
|
|
71
|
-
const timer = () => timerFor(app, system, context.env);
|
|
72
|
-
// The command line refuses a schedule under the lock; a caller that gets here still passes the lock on.
|
|
73
|
-
const readOnly = readOnlyLockOf(app, context.env);
|
|
74
|
-
const environment = () => ({
|
|
75
|
-
[`${app.envPrefix}_PROFILE`]: context.profile,
|
|
76
|
-
...locationVariables(app, context.env),
|
|
77
|
-
// The timer starts with almost no PATH; an agent runs `max` and its own tools by name.
|
|
78
|
-
...(context.env.PATH ? { PATH: context.env.PATH } : {}),
|
|
79
|
-
...(readOnly ? { [readOnly]: "1" } : {}),
|
|
80
|
-
});
|
|
81
|
-
const save = async (schedule) => {
|
|
82
|
-
const file = scheduleFile(state, schedule.name);
|
|
83
|
-
const replaced = existsSync(file);
|
|
84
|
-
writeSecurely(file, `${JSON.stringify(schedule, null, 2)}\n`, 0o600);
|
|
85
|
-
await timer().install(schedule, [...system.entry, "schedule", "exec", file]);
|
|
86
|
-
say(scheduleAnswer(schedule, { file, timer: timer().where(schedule.name), replaced }), [
|
|
87
|
-
`${replaced ? "Replaced" : "Added"} ${schedule.name}: ${describeWhen(schedule.when)}.`,
|
|
88
|
-
`It runs: ${schedule.kind === "agent" ? schedule.argv.join(" ") : `${app.command} ${schedule.argv.join(" ")}`}`,
|
|
89
|
-
`Output: ${join(dirname(file), "runs")} · now: ${lineOf(self, `schedule run ${schedule.name}`)} · stop: ${lineOf(self, `schedule remove ${schedule.name}`)}`,
|
|
90
|
-
]);
|
|
91
|
-
};
|
|
92
|
-
return { context, system, state, say, timer, environment, save };
|
|
93
|
-
};
|
|
94
|
-
withWhen(grantsAuthority(annotate(command.command("command"), { effects: LOCAL_WRITE }))
|
|
95
|
-
.description(`run ${app.command} <args…> on a timer — reads save what they read, so this is how a chat's history fills the store`)
|
|
96
|
-
.argument("<name>", "lowercase letters, digits and dashes")
|
|
97
|
-
.argument("<args...>", `the ${app.command} command after its name, e.g. -- messages list <chat> --limit 50`)).action(async function (name, args) {
|
|
98
|
-
const { environment, save } = prepare(this);
|
|
99
|
-
if (args[0] === "schedule")
|
|
100
|
-
throw new CliError("validation_error", "a schedule cannot run schedule");
|
|
101
|
-
await save({
|
|
102
|
-
name: scheduleName(name),
|
|
103
|
-
kind: "command",
|
|
104
|
-
argv: args,
|
|
105
|
-
when: whenOf(this.opts(), COMMAND_MINIMUM_MINUTES),
|
|
106
|
-
environment: environment(),
|
|
107
|
-
createdAt: new Date().toISOString(),
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
withWhen(grantsAuthority(annotate(command.command("agent"), { effects: LOCAL_WRITE }))
|
|
111
|
-
.description(`start an agent (claude or codex) with a prompt on a timer; it runs with ${app.envPrefix}_READ_ONLY=1, so it reads and drafts and cannot send — its answer is kept and you are notified`)
|
|
112
|
-
.argument("<name>", "lowercase letters, digits and dashes")
|
|
113
|
-
.addArgument(new Argument("<harness>", "the agent to start").choices(HARNESSES))
|
|
114
|
-
.argument("<prompt>", `what to do, e.g. "run the daily follow-up review: ${app.command} skill show follow-ups"`)
|
|
115
|
-
.addOption(new Option("--max-cost <usd>", "claude only: stop a run that costs more than this, in US dollars").argParser(Number))).action(async function (name, harness, prompt) {
|
|
116
|
-
const { context, system, environment, save } = prepare(this);
|
|
117
|
-
const options = this.opts();
|
|
118
|
-
const program = findProgram(harness, context.env, system.platform);
|
|
119
|
-
if (!program)
|
|
120
|
-
throw new CliError("not_found", `${harness} is not on PATH`);
|
|
121
|
-
const paths = resolvePaths({ appName: app.appName, prefix: app.envPrefix, env: context.env });
|
|
122
|
-
const argv = harness === "claude"
|
|
123
|
-
? [
|
|
124
|
-
program,
|
|
125
|
-
"-p",
|
|
126
|
-
prompt,
|
|
127
|
-
// The owner's own settings may let every tool run (bypassPermissions); here only the CLI may.
|
|
128
|
-
"--permission-mode",
|
|
129
|
-
"dontAsk",
|
|
130
|
-
"--allowedTools",
|
|
131
|
-
`Bash(${app.command}:*)`,
|
|
132
|
-
"--no-session-persistence",
|
|
133
|
-
...(options.maxCost ? ["--max-budget-usd", String(options.maxCost)] : []),
|
|
134
|
-
]
|
|
135
|
-
: [
|
|
136
|
-
program,
|
|
137
|
-
"exec",
|
|
138
|
-
"--skip-git-repo-check",
|
|
139
|
-
"--ephemeral",
|
|
140
|
-
"--sandbox",
|
|
141
|
-
"workspace-write",
|
|
142
|
-
// The CLI reaches the messenger and writes its store and state, and nothing else.
|
|
143
|
-
"-c",
|
|
144
|
-
"sandbox_workspace_write.network_access=true",
|
|
145
|
-
...[dirname(storePath(context.env)), paths.state, paths.config, paths.cache].flatMap((dir) => [
|
|
146
|
-
"--add-dir",
|
|
147
|
-
dir,
|
|
148
|
-
]),
|
|
149
|
-
prompt,
|
|
150
|
-
];
|
|
151
|
-
await save({
|
|
152
|
-
name: scheduleName(name),
|
|
153
|
-
kind: "agent",
|
|
154
|
-
argv,
|
|
155
|
-
when: whenOf(options, AGENT_MINIMUM_MINUTES),
|
|
156
|
-
environment: environment(),
|
|
157
|
-
createdAt: new Date().toISOString(),
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
withQuery(annotate(command.command("list"), { effects: LOCAL_READ }), SCHEDULES_QUERY)
|
|
161
|
-
.description("the schedules, when they run and how their last run went")
|
|
162
|
-
.action(function (words) {
|
|
163
|
-
const filter = scheduleFilter(words);
|
|
164
|
-
const { context, state } = prepare(this);
|
|
165
|
-
const rows = listSchedules(state, filter).map((schedule) => ({
|
|
166
|
-
name: schedule.name,
|
|
167
|
-
kind: schedule.kind,
|
|
168
|
-
when: describeWhen(schedule.when),
|
|
169
|
-
last: lastRun(scheduleFile(state, schedule.name)) ?? null,
|
|
170
|
-
}));
|
|
171
|
-
const answer = listOf("schedule list", {
|
|
172
|
-
items: rows.map((row) => snakeKeys(row)),
|
|
173
|
-
limit: Math.max(1, rows.length),
|
|
174
|
-
has_more: false,
|
|
175
|
-
});
|
|
176
|
-
renderAnswer(this, context, answer, () => {
|
|
177
|
-
if (rows.length === 0) {
|
|
178
|
-
context.renderer.note(`no schedules — \`${lineOf(this, "schedule command|agent")}\` adds one`);
|
|
179
|
-
return "";
|
|
180
|
-
}
|
|
181
|
-
return rows
|
|
182
|
-
.map(({ name, kind, when, last }) => [
|
|
183
|
-
name,
|
|
184
|
-
kind,
|
|
185
|
-
when,
|
|
186
|
-
last
|
|
187
|
-
? `last ${last.at.slice(0, 16).replace("T", " ")} exit ${last.exitCode}` +
|
|
188
|
-
(last.failures
|
|
189
|
-
? ` — failing since ${(last.failingSince ?? last.at).slice(0, 16).replace("T", " ")}, ` +
|
|
190
|
-
`${last.failures} in a row${last.reason ? `: ${last.reason}` : ""} — ${last.output}`
|
|
191
|
-
: "")
|
|
192
|
-
: "not run yet",
|
|
193
|
-
].join(" "))
|
|
194
|
-
.join("\n");
|
|
195
|
-
});
|
|
196
|
-
});
|
|
197
|
-
annotate(command.command("run"), { effects: REMOTE_WRITE })
|
|
198
|
-
.description("run a schedule now, as its timer would, and wait for it")
|
|
199
|
-
.argument("<name>")
|
|
200
|
-
.action(async function (name) {
|
|
201
|
-
const { context, system, state, say, timer } = prepare(this);
|
|
202
|
-
const last = await runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
|
|
203
|
-
timer: timer(),
|
|
204
|
-
});
|
|
205
|
-
say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
|
|
206
|
-
if (last.exitCode !== 0)
|
|
207
|
-
setExitCode(this, 1);
|
|
208
|
-
});
|
|
209
|
-
grantsAuthority(annotate(command.command("remove"), { effects: LOCAL_WRITE }))
|
|
210
|
-
.description("stop a schedule and remove its timer; its past output stays")
|
|
211
|
-
.argument("<name>")
|
|
212
|
-
.action(async function (name) {
|
|
213
|
-
const { state, say, timer } = prepare(this);
|
|
214
|
-
const file = scheduleFile(state, scheduleName(name));
|
|
215
|
-
if (!existsSync(file))
|
|
216
|
-
throw new CliError("not_found", `no schedule ${name} — \`${lineOf(this, "schedule list")}\``);
|
|
217
|
-
await timer().remove(name);
|
|
218
|
-
rmSync(file, { force: true });
|
|
219
|
-
say({ name, removed: true }, [`Removed ${name}.`]);
|
|
220
|
-
});
|
|
221
|
-
annotate(command.command("exec"), { effects: REMOTE_WRITE })
|
|
222
|
-
.description("what a timer runs, given the schedule's file; to run one by hand, use run")
|
|
223
|
-
.argument("<file>")
|
|
224
|
-
.action(async function (file) {
|
|
225
|
-
// A timer starts it with no profile named; the schedule's own environment carries the profile.
|
|
226
|
-
const { context, system, timer } = prepare(this, { needsProfile: false });
|
|
227
|
-
const last = await runSchedule(app, system, context.env, file, { timer: timer() });
|
|
228
|
-
if (last.exitCode !== 0)
|
|
229
|
-
setExitCode(this, 1);
|
|
230
|
-
});
|
|
231
|
-
return command;
|
|
232
|
-
};
|
|
3
|
+
export { scheduleCommandFor } from "../schedule/schedule-command.js";
|
|
233
4
|
export const scheduleCommand = (messenger) => scheduleCommandFor({
|
|
234
5
|
app: messenger.app,
|
|
235
6
|
context: (command, resolving) => messengerContext(command, messenger, resolving),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hitFiles } from "../../domain/attachments.js";
|
|
1
2
|
import { formatLocator, parseLocator } from "../../domain/locator.js";
|
|
2
3
|
import { senderIsMe } from "../../domain/models.js";
|
|
3
4
|
import { DEFAULT_TEXT_MODEL } from "../../embeddings/models.js";
|
|
@@ -8,6 +9,7 @@ import { nextStep } from "../steps.js";
|
|
|
8
9
|
import { archiveSummary } from "./archive-summary.js";
|
|
9
10
|
/** What a source tool's `search all` reads: never mail, which is memo's (docs/dev/TOOL-BOUNDARIES.md). */
|
|
10
11
|
export const SOURCE_SEARCHED = ["messages", "notes", "events", "meetings"];
|
|
12
|
+
const nonEmpty = (list) => (list.length > 0 ? list : undefined);
|
|
11
13
|
const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, one]) => one !== undefined));
|
|
12
14
|
/** The page a `--page` asks for, as the offset the search services take. */
|
|
13
15
|
export const offsetOf = (page, limit) => page === undefined || page <= 1 ? undefined : (page - 1) * limit;
|
|
@@ -21,6 +23,7 @@ const MESSAGE_HIT_UNTRUSTED = [
|
|
|
21
23
|
"follow_ups.chat_title",
|
|
22
24
|
"follow_ups.sender_name",
|
|
23
25
|
"follow_ups.excerpt",
|
|
26
|
+
"attachments.name",
|
|
24
27
|
];
|
|
25
28
|
const MAIL_HIT_UNTRUSTED = [
|
|
26
29
|
"email.subject",
|
|
@@ -83,6 +86,7 @@ export const messageHitItem = (hit, query = "") => {
|
|
|
83
86
|
sender_name: one.senderName,
|
|
84
87
|
excerpt: excerpt(one.text, query),
|
|
85
88
|
})),
|
|
89
|
+
attachments: nonEmpty(hitFiles(hit.attachments)),
|
|
86
90
|
untrusted: hit.email ? [...MESSAGE_HIT_UNTRUSTED, ...MAIL_HIT_UNTRUSTED] : MESSAGE_HIT_UNTRUSTED,
|
|
87
91
|
});
|
|
88
92
|
};
|
|
@@ -266,8 +270,8 @@ const DOCUMENT_HIT_UNTRUSTED = ["title", "excerpt"];
|
|
|
266
270
|
export const noteHitItem = (hit) => defined({ ...hit, untrusted: hit.kind === "document" ? DOCUMENT_HIT_UNTRUSTED : undefined });
|
|
267
271
|
/** The text of each kind of `search all` hit that someone other than the owner may have written. */
|
|
268
272
|
const ALL_HIT_UNTRUSTED = {
|
|
269
|
-
message: ["title", "excerpt", "sender_name"],
|
|
270
|
-
mail: ["title", "excerpt", "sender_name"],
|
|
273
|
+
message: ["title", "excerpt", "sender_name", "attachments.name"],
|
|
274
|
+
mail: ["title", "excerpt", "sender_name", "attachments.name"],
|
|
271
275
|
document: DOCUMENT_HIT_UNTRUSTED,
|
|
272
276
|
note: undefined,
|
|
273
277
|
event: ["title", "excerpt"],
|
|
@@ -300,6 +304,7 @@ export const allHitItem = (item) => item.kind === "meeting"
|
|
|
300
304
|
sender_is_me: item.senderIsMe,
|
|
301
305
|
sender_ref: item.from?.ref,
|
|
302
306
|
found_by: item.foundBy,
|
|
307
|
+
attachments: item.attachments === undefined ? undefined : nonEmpty(item.attachments),
|
|
303
308
|
untrusted: ALL_HIT_UNTRUSTED[item.kind],
|
|
304
309
|
});
|
|
305
310
|
/** Meaning was not searched because the model is not downloaded; `--exact` leaving it out is no gap. */
|
|
@@ -1,12 +1,13 @@
|
|
|
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 { topicIcon } from "../../entities/index.js";
|
|
4
5
|
import { capability } from "../../ports/messenger.js";
|
|
5
6
|
import { ownerOnlyOption, REMOTE_READ, REMOTE_WRITE } from "../effects.js";
|
|
6
7
|
import { itemOf, listOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
7
|
-
import { typedQuery, withQuery } from "../list-query.js";
|
|
8
|
+
import { listQuery, typedQuery, unfilteredQuery, withQuery } from "../list-query.js";
|
|
8
9
|
import { window, withPaging } from "../paging.js";
|
|
9
|
-
import { lines, pageOf, topicAnswer } from "./chat-answers.js";
|
|
10
|
+
import { lines, pageOf, topicAnswer, wholeList } from "./chat-answers.js";
|
|
10
11
|
import { messengerContext } from "./context.js";
|
|
11
12
|
import { TOPICS_QUERY, topicTitle } from "./list-result-queries.js";
|
|
12
13
|
const listed = (messenger) => async function (chat, value) {
|
|
@@ -47,6 +48,17 @@ export const topicsCommand = (messenger) => {
|
|
|
47
48
|
const shown = await context.withServices((services) => services.topics.show(chat, topic));
|
|
48
49
|
renderAnswer(this, context, itemOf(topicAnswer(shown)));
|
|
49
50
|
});
|
|
51
|
+
const icons = new Command("icons").description("the emojis a forum topic can take as its icon");
|
|
52
|
+
topics.addCommand(icons);
|
|
53
|
+
icons.addCommand(withQuery(annotate(new Command("list"), {
|
|
54
|
+
effects: REMOTE_READ,
|
|
55
|
+
examples: [`${messenger.app.command} personal topics icons list`],
|
|
56
|
+
}).description("the emojis `--emoji` takes in topics create and topics edit"), TOPIC_ICONS_QUERY).action(async function (words) {
|
|
57
|
+
const context = messengerContext(this, messenger);
|
|
58
|
+
listQuery(typedQuery(words), TOPIC_ICONS_QUERY, "topics icons list");
|
|
59
|
+
const icons = await context.withServices((services) => services.topics.icons());
|
|
60
|
+
renderAnswer(this, context, wholeList("topics icons list", icons.map((emoji) => ({ emoji }))), lines(({ emoji }) => emoji));
|
|
61
|
+
}));
|
|
50
62
|
annotate(topics.command("enable"), { effects: REMOTE_WRITE })
|
|
51
63
|
.description("enable forum topics; only the owner, with an explicit upgrade for a basic group")
|
|
52
64
|
.argument("<chat>", messenger.chatArgument)
|
|
@@ -61,22 +73,25 @@ export const topicsCommand = (messenger) => {
|
|
|
61
73
|
.argument("<chat>", messenger.chatArgument)
|
|
62
74
|
.argument("<title>", "the topic title, at most 128 UTF-8 bytes")
|
|
63
75
|
.option("--operation-id <id>", "identify this creation attempt; an already sent or unknown id is refused")
|
|
76
|
+
.option("--emoji <emoji>", "the topic's icon, one of `topics icons list`")
|
|
64
77
|
.action(async function (chat, title) {
|
|
65
78
|
const context = messengerContext(this, messenger);
|
|
66
|
-
const
|
|
79
|
+
const { operationId, emoji } = this.opts();
|
|
80
|
+
const created = await context.withServices((services) => services.topics.create(chat, title, { sendId: operationId, ...(emoji === undefined ? {} : { icon: emoji }) }));
|
|
67
81
|
renderAnswer(this, context, itemOf(snakeKeys(created)));
|
|
68
82
|
});
|
|
69
83
|
annotate(topics.command("edit"), { effects: REMOTE_WRITE })
|
|
70
|
-
.description("rename, close or
|
|
84
|
+
.description("rename, close, reopen or give an icon to a forum topic")
|
|
71
85
|
.argument("<chat>", messenger.chatArgument)
|
|
72
86
|
.argument("<topic>", "the topic id, from `topics list`")
|
|
73
87
|
.option("--title <title>", "the new title, at most 128 UTF-8 bytes")
|
|
74
88
|
.option("--closed <on|off>", "on closes the topic to new messages, off reopens it")
|
|
75
89
|
.option("--pinned <on|off>", "on pins the topic at the top of the list, off unpins it")
|
|
76
90
|
.option("--hidden <on|off>", "on hides the General topic from the topic list, off shows it")
|
|
91
|
+
.option("--emoji <emoji>", "the topic's icon, one of `topics icons list`; none removes it")
|
|
77
92
|
.action(async function (chat, topic) {
|
|
78
93
|
const context = messengerContext(this, messenger);
|
|
79
|
-
const { title, closed, pinned, hidden } = this.opts();
|
|
94
|
+
const { title, closed, pinned, hidden, emoji } = this.opts();
|
|
80
95
|
const closes = onOff("--closed", closed);
|
|
81
96
|
const pins = onOff("--pinned", pinned);
|
|
82
97
|
const hides = onOff("--hidden", hidden);
|
|
@@ -85,6 +100,7 @@ export const topicsCommand = (messenger) => {
|
|
|
85
100
|
...(closes === undefined ? {} : { closed: closes }),
|
|
86
101
|
...(pins === undefined ? {} : { pinned: pins }),
|
|
87
102
|
...(hides === undefined ? {} : { hidden: hides }),
|
|
103
|
+
...(emoji === undefined ? {} : { icon: emoji === "none" ? null : emoji }),
|
|
88
104
|
}));
|
|
89
105
|
renderAnswer(this, context, itemOf(snakeKeys(done)));
|
|
90
106
|
});
|
|
@@ -107,6 +123,7 @@ export const topicsCommand = (messenger) => {
|
|
|
107
123
|
});
|
|
108
124
|
return topics;
|
|
109
125
|
};
|
|
126
|
+
const TOPIC_ICONS_QUERY = unfilteredQuery("topic_icon", topicIcon);
|
|
110
127
|
const onOff = (flag, value) => {
|
|
111
128
|
if (value === undefined)
|
|
112
129
|
return undefined;
|