@pellux/goodvibes-tui 1.24.2 → 1.26.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 +32 -0
- package/README.md +1 -1
- package/bin/goodvibes-daemon +24 -0
- package/docs/foundation-artifacts/operator-contract.json +18234 -12253
- package/package.json +8 -7
- package/src/cli/help.ts +10 -0
- package/src/cli/redaction.ts +68 -3
- package/src/config/goodvibes-home.ts +149 -0
- package/src/config/secret-config.ts +64 -8
- package/src/core/turn-event-wiring.ts +7 -16
- package/src/core/workstream-notification.ts +69 -0
- package/src/daemon/cli.ts +62 -7
- package/src/daemon/handlers/credentials.ts +15 -5
- package/src/daemon/handlers/index.ts +7 -6
- package/src/daemon/send/channels.ts +200 -0
- package/src/daemon/send/command.ts +332 -0
- package/src/daemon/send/composition.ts +99 -0
- package/src/daemon/send/failure-text.ts +93 -0
- package/src/daemon/send/inert-text.ts +225 -0
- package/src/daemon/send/stdin.ts +24 -0
- package/src/input/commands/calendar-runtime.ts +245 -0
- package/src/input/commands/config.ts +68 -2
- package/src/input/commands/connection-status.ts +258 -0
- package/src/input/commands/local-provider-runtime.ts +9 -1
- package/src/input/commands/mail-runtime.ts +267 -0
- package/src/input/commands/operator-runtime.ts +6 -1
- package/src/input/commands/payment-card-intake.ts +200 -0
- package/src/input/commands/profile-render.ts +250 -0
- package/src/input/commands/profile-runtime.ts +362 -0
- package/src/input/commands/profile-types.ts +381 -0
- package/src/input/commands/review-runtime.ts +13 -1
- package/src/input/commands.ts +11 -0
- package/src/input/composer-editor.ts +4 -0
- package/src/input/onboarding/onboarding-wizard-apply.ts +13 -1
- package/src/input/payments-config.ts +167 -0
- package/src/input/provider-key-intake.ts +10 -2
- package/src/input/settings-modal-commit.ts +146 -0
- package/src/input/settings-modal-connections.ts +102 -0
- package/src/input/settings-modal-data.ts +30 -0
- package/src/input/settings-modal-reset.ts +6 -2
- package/src/input/settings-modal-secrets.ts +9 -2
- package/src/input/settings-modal-types.ts +19 -3
- package/src/input/settings-modal.ts +54 -107
- package/src/main.ts +7 -2
- package/src/panels/modals/marketplace-modal.ts +5 -0
- package/src/panels/modals/skills-modal.ts +5 -0
- package/src/renderer/settings-modal-connections.ts +63 -0
- package/src/renderer/settings-modal-helpers.ts +6 -0
- package/src/renderer/settings-modal.ts +21 -1
- package/src/runtime/bootstrap-core.ts +1 -1
- package/src/runtime/bootstrap.ts +2 -2
- package/src/runtime/context.ts +9 -0
- package/src/runtime/credential-composition.ts +54 -0
- package/src/runtime/daemon-handler-composition.ts +0 -4
- package/src/runtime/install-self-check-startup.ts +23 -0
- package/src/runtime/mail-composition.ts +65 -0
- package/src/runtime/onboarding/types.ts +6 -1
- package/src/runtime/path-shadow-startup.ts +243 -0
- package/src/runtime/path-shadow.ts +418 -0
- package/src/runtime/reachability-notice.ts +100 -0
- package/src/runtime/runtime-services-types.ts +243 -0
- package/src/runtime/services.ts +30 -224
- package/src/shell/ui-openers.ts +7 -0
- package/src/verification/verification-ledger.ts +158 -0
- package/src/version.ts +1 -1
- package/src/daemon/handlers/calendar/caldav-client.ts +0 -662
- package/src/daemon/handlers/calendar/ics.ts +0 -556
- package/src/daemon/handlers/calendar/index.ts +0 -316
- package/src/daemon/handlers/email/config.ts +0 -164
- package/src/daemon/handlers/email/imap-connector.ts +0 -441
- package/src/daemon/handlers/email/imap-parsing.ts +0 -499
- package/src/daemon/handlers/email/index.ts +0 -43
- package/src/daemon/handlers/email/read-handlers.ts +0 -80
- package/src/daemon/handlers/email/runtime.ts +0 -140
- package/src/daemon/handlers/email/smtp-connector.ts +0 -557
- package/src/daemon/handlers/email/validation.ts +0 -147
- package/src/daemon/handlers/email/write-handlers.ts +0 -133
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
2
|
-
import type { SecretsManager } from '../../config/secrets.ts';
|
|
2
|
+
import type { SecretsManager, SecretScope } from '../../config/secrets.ts';
|
|
3
3
|
import {
|
|
4
4
|
buildGoodVibesSecretKey,
|
|
5
5
|
isSecretReferenceValue,
|
|
@@ -16,11 +16,21 @@ export interface DaemonCredentialStore {
|
|
|
16
16
|
* (e.g. 'surfaces.slack.botToken' → GOODVIBES_SURFACES_SLACK_BOT_TOKEN).
|
|
17
17
|
*/
|
|
18
18
|
resolveConfigSecret(configKey: string): Promise<string | null>;
|
|
19
|
-
/**
|
|
19
|
+
/**
|
|
20
|
+
* Store a daemon-owned credential. scope defaults 'daemon'.
|
|
21
|
+
*
|
|
22
|
+
* This is the DAEMON's own credential store: every caller is daemon code, and
|
|
23
|
+
* every value written through it is one the daemon itself has to read back —
|
|
24
|
+
* including its own generated draft encryption key (createAtRestCipher below,
|
|
25
|
+
* which calls put() with no scope at all). Defaulting to 'user' filed those
|
|
26
|
+
* in a surface-scoped tier, so the daemon's own material lived somewhere it
|
|
27
|
+
* only found by accident, and a value written on one surface's behalf was
|
|
28
|
+
* invisible to the next.
|
|
29
|
+
*/
|
|
20
30
|
put(
|
|
21
31
|
secretKey: string,
|
|
22
32
|
value: string,
|
|
23
|
-
opts?: { scope?:
|
|
33
|
+
opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
|
|
24
34
|
): Promise<void>;
|
|
25
35
|
has(secretKey: string): Promise<boolean>;
|
|
26
36
|
}
|
|
@@ -45,10 +55,10 @@ export function createDaemonCredentialStore(secrets: SecretsManager): DaemonCred
|
|
|
45
55
|
async put(
|
|
46
56
|
secretKey: string,
|
|
47
57
|
value: string,
|
|
48
|
-
opts?: { scope?:
|
|
58
|
+
opts?: { scope?: SecretScope; medium?: 'plaintext' | 'secure' },
|
|
49
59
|
): Promise<void> {
|
|
50
60
|
await secrets.set(secretKey, value, {
|
|
51
|
-
scope: opts?.scope ?? '
|
|
61
|
+
scope: opts?.scope ?? 'daemon',
|
|
52
62
|
medium: opts?.medium ?? 'secure',
|
|
53
63
|
});
|
|
54
64
|
},
|
|
@@ -56,10 +56,6 @@ export interface DaemonHandlerSurfaceProviders {
|
|
|
56
56
|
readonly registerInbox: (ctx: HandlerContext, routing: RoutingRegistration) => Unregister;
|
|
57
57
|
/** channels.drafts.* */
|
|
58
58
|
readonly registerDrafts: SurfaceRegister;
|
|
59
|
-
/** calendar.* */
|
|
60
|
-
readonly registerCalendar: SurfaceRegister;
|
|
61
|
-
/** email.* */
|
|
62
|
-
readonly registerEmail: SurfaceRegister;
|
|
63
59
|
/** remote.peers.* — supplies the host DistributedRuntimeRouteService + dispatch adapter. */
|
|
64
60
|
readonly registerRemote: (ctx: HandlerContext) => RemoteSurfaceRegistration;
|
|
65
61
|
}
|
|
@@ -82,8 +78,13 @@ export function registerDaemonHandlers(
|
|
|
82
78
|
|
|
83
79
|
teardowns.push(providers.registerInbox(ctx, routing));
|
|
84
80
|
teardowns.push(providers.registerDrafts(ctx));
|
|
85
|
-
|
|
86
|
-
|
|
81
|
+
// calendar.* and email.* are NOT registered here any more. Both are served
|
|
82
|
+
// by the SDK (control-plane/routes/{calendar,email}.ts over the platform
|
|
83
|
+
// CalDAV/Google and IMAP/SMTP implementations), registered through
|
|
84
|
+
// registerGatewayVerbGroups in runtime/services.ts. This product used to
|
|
85
|
+
// carry its own handlers for the same descriptor ids and, registering later,
|
|
86
|
+
// won — two implementations behind one path, which is the drift the hoist
|
|
87
|
+
// exists to end.
|
|
87
88
|
|
|
88
89
|
const remote = providers.registerRemote(ctx);
|
|
89
90
|
teardowns.push(remote.unregister);
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* channels.ts — which channels `goodvibes-daemon send` can reach, and which one
|
|
3
|
+
* it uses when the operator names none.
|
|
4
|
+
*
|
|
5
|
+
* The surface list, labels and required-setup keys are IMPORTED from
|
|
6
|
+
* `src/cli/surface-command.ts` rather than restated, so `goodvibes surfaces
|
|
7
|
+
* list` and `goodvibes-daemon send --list` can never disagree about what a
|
|
8
|
+
* channel is called or what it needs. What this module adds on top is the two
|
|
9
|
+
* facts that command needs and `SURFACE_CONFIGS` does not carry: the routable
|
|
10
|
+
* `ChannelDeliverySurfaceKind` for each surface id (they differ in case —
|
|
11
|
+
* `googleChat` vs `google-chat`), and the settings key holding each surface's
|
|
12
|
+
* default destination.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ConfigKey, ConfigManager } from '../../config/index.ts';
|
|
16
|
+
import type { ChannelDeliverySurfaceKind } from '@pellux/goodvibes-sdk/platform/channels';
|
|
17
|
+
import { SURFACE_CONFIGS } from '../../cli/surface-command.ts';
|
|
18
|
+
import { canRenderInert } from './inert-text.ts';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The settings key holding each supported surface's default destination — the
|
|
22
|
+
* chat, channel, topic or URL a message goes to when `--to` is not given.
|
|
23
|
+
*
|
|
24
|
+
* These are the SAME keys the delivery strategies in the SDK's
|
|
25
|
+
* `strategies-core.ts` fall back to (`surfaces.telegram.defaultChatId`,
|
|
26
|
+
* `surfaces.discord.defaultChannelId`, …). Only surfaces with a verified inert
|
|
27
|
+
* transform appear; see inert-text.ts for why the rest are refused rather than
|
|
28
|
+
* sent to.
|
|
29
|
+
*/
|
|
30
|
+
const DESTINATION_KEY_BY_SURFACE_ID = {
|
|
31
|
+
telegram: 'surfaces.telegram.defaultChatId',
|
|
32
|
+
ntfy: 'surfaces.ntfy.topic',
|
|
33
|
+
discord: 'surfaces.discord.defaultChannelId',
|
|
34
|
+
slack: 'surfaces.slack.defaultChannel',
|
|
35
|
+
googleChat: 'surfaces.googleChat.webhookUrl',
|
|
36
|
+
webhook: 'surfaces.webhook.defaultTarget',
|
|
37
|
+
signal: 'surfaces.signal.defaultRecipient',
|
|
38
|
+
whatsapp: 'surfaces.whatsapp.defaultRecipient',
|
|
39
|
+
imessage: 'surfaces.imessage.defaultChatId',
|
|
40
|
+
msteams: 'surfaces.msteams.defaultConversationId',
|
|
41
|
+
bluebubbles: 'surfaces.bluebubbles.defaultChatGuid',
|
|
42
|
+
mattermost: 'surfaces.mattermost.defaultChannelId',
|
|
43
|
+
matrix: 'surfaces.matrix.defaultRoomId',
|
|
44
|
+
// Not cast: every value is checked against the real ConfigKey union, so a
|
|
45
|
+
// key that is renamed or misspelled in the schema fails the build here
|
|
46
|
+
// instead of reading `undefined` at send time and reporting the channel as
|
|
47
|
+
// unconfigured.
|
|
48
|
+
} as const satisfies Readonly<Record<string, ConfigKey>>;
|
|
49
|
+
|
|
50
|
+
/** Surface ids whose routable kind is not simply the id itself. */
|
|
51
|
+
const SURFACE_KIND_BY_ID: Readonly<Record<string, ChannelDeliverySurfaceKind>> = {
|
|
52
|
+
googleChat: 'google-chat',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `--to` addresses on each channel, in that channel's own vocabulary.
|
|
57
|
+
*
|
|
58
|
+
* This is naming, not a scheme: `--to` becomes `ChannelDeliveryTarget.address`,
|
|
59
|
+
* which is the first thing every strategy in `strategies-core.ts`,
|
|
60
|
+
* `strategies-bridge.ts` and `strategies-enterprise.ts` already checks before
|
|
61
|
+
* falling back to its configured default. Nothing new is invented — the label
|
|
62
|
+
* exists so `--list` and the help can say "topic" where the channel says topic
|
|
63
|
+
* and "chat id" where it says chat id, rather than making the operator work out
|
|
64
|
+
* what an "address" is for ntfy.
|
|
65
|
+
*/
|
|
66
|
+
const ADDRESS_LABEL_BY_SURFACE_ID: Readonly<Record<string, string>> = {
|
|
67
|
+
telegram: 'chat id',
|
|
68
|
+
ntfy: 'topic',
|
|
69
|
+
discord: 'channel id',
|
|
70
|
+
slack: 'channel id',
|
|
71
|
+
googleChat: 'webhook URL',
|
|
72
|
+
webhook: 'URL',
|
|
73
|
+
signal: 'recipient',
|
|
74
|
+
whatsapp: 'recipient',
|
|
75
|
+
imessage: 'chat id',
|
|
76
|
+
msteams: 'conversation id',
|
|
77
|
+
bluebubbles: 'chat GUID',
|
|
78
|
+
mattermost: 'channel id',
|
|
79
|
+
matrix: 'room id',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export interface SendChannel {
|
|
83
|
+
/** The id an operator types after `--channel`, e.g. `telegram`. */
|
|
84
|
+
readonly id: string;
|
|
85
|
+
/** Human label, shared with `goodvibes surfaces list`. */
|
|
86
|
+
readonly label: string;
|
|
87
|
+
/** The kind the channel delivery router routes on. */
|
|
88
|
+
readonly surfaceKind: ChannelDeliverySurfaceKind;
|
|
89
|
+
/** `surfaces.<id>.enabled`. */
|
|
90
|
+
readonly enabledKey: ConfigKey;
|
|
91
|
+
/** The settings key holding this channel's default destination. */
|
|
92
|
+
readonly destinationKey: ConfigKey;
|
|
93
|
+
/** What `--to` names on this channel, in the channel's own words ("topic"). */
|
|
94
|
+
readonly addressLabel: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Every channel this command can deliver to. Derived, not restated: a surface
|
|
99
|
+
* that gains a delivery strategy and an inert transform appears here by adding
|
|
100
|
+
* its destination key above, and a surface that loses its entry in
|
|
101
|
+
* `SURFACE_CONFIGS` disappears from both commands at once.
|
|
102
|
+
*/
|
|
103
|
+
const destinationKeys: Readonly<Record<string, ConfigKey>> = DESTINATION_KEY_BY_SURFACE_ID;
|
|
104
|
+
|
|
105
|
+
export const SEND_CHANNELS: readonly SendChannel[] = SURFACE_CONFIGS
|
|
106
|
+
.flatMap(([id, label]): SendChannel[] => {
|
|
107
|
+
const destinationKey = destinationKeys[id];
|
|
108
|
+
if (!destinationKey) return [];
|
|
109
|
+
const surfaceKind = SURFACE_KIND_BY_ID[id] ?? (id as ChannelDeliverySurfaceKind);
|
|
110
|
+
if (!canRenderInert(surfaceKind)) return [];
|
|
111
|
+
return [{
|
|
112
|
+
id,
|
|
113
|
+
label,
|
|
114
|
+
surfaceKind,
|
|
115
|
+
enabledKey: `surfaces.${id}.enabled` as ConfigKey,
|
|
116
|
+
destinationKey,
|
|
117
|
+
addressLabel: ADDRESS_LABEL_BY_SURFACE_ID[id] ?? 'address',
|
|
118
|
+
}];
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Look a channel up by the id an operator typed, accepting either the settings
|
|
123
|
+
* id (`googleChat`) or the routable kind (`google-chat`) — the two spellings
|
|
124
|
+
* are both visible in this product's own output, and making the operator
|
|
125
|
+
* remember which one this command wants would be a trap.
|
|
126
|
+
*/
|
|
127
|
+
export function findSendChannel(name: string): SendChannel | undefined {
|
|
128
|
+
const wanted = name.trim().toLowerCase();
|
|
129
|
+
return SEND_CHANNELS.find((channel) =>
|
|
130
|
+
channel.id.toLowerCase() === wanted || channel.surfaceKind.toLowerCase() === wanted);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ChannelReadiness {
|
|
134
|
+
readonly channel: SendChannel;
|
|
135
|
+
/** `surfaces.<id>.enabled` is true. */
|
|
136
|
+
readonly enabled: boolean;
|
|
137
|
+
/** The configured default destination, or null when the key is blank. */
|
|
138
|
+
readonly destination: string | null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function readSetting(config: Pick<ConfigManager, 'get'>, key: ConfigKey): string {
|
|
142
|
+
const value = config.get(key);
|
|
143
|
+
return typeof value === 'string' ? value.trim() : value === undefined || value === null ? '' : String(value).trim();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Read every channel's live state from config.
|
|
148
|
+
*
|
|
149
|
+
* The `ConfigManager` handed in must be one built with a `homeDir`, because
|
|
150
|
+
* every `surfaces.*` key is daemon-owned: the manager overlays
|
|
151
|
+
* `<home>/.goodvibes/daemon/settings.json` LAST, which is the only reason a
|
|
152
|
+
* client process can see a bot token the daemon owns. A manager built without
|
|
153
|
+
* that overlay reads a surface silo holding nothing but `setupVersion` and
|
|
154
|
+
* reports every channel as unconfigured.
|
|
155
|
+
*/
|
|
156
|
+
export function readChannelReadiness(config: Pick<ConfigManager, 'get'>): readonly ChannelReadiness[] {
|
|
157
|
+
return SEND_CHANNELS.map((channel) => {
|
|
158
|
+
const destination = readSetting(config, channel.destinationKey);
|
|
159
|
+
return {
|
|
160
|
+
channel,
|
|
161
|
+
enabled: config.get(channel.enabledKey) === true,
|
|
162
|
+
destination: destination.length > 0 ? destination : null,
|
|
163
|
+
};
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type DefaultChannelResolution =
|
|
168
|
+
| { readonly kind: 'resolved'; readonly channel: SendChannel; readonly destination: string; readonly reason: string }
|
|
169
|
+
| { readonly kind: 'none'; readonly candidates: readonly ChannelReadiness[] }
|
|
170
|
+
| { readonly kind: 'ambiguous'; readonly candidates: readonly ChannelReadiness[] };
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Which channel a `send` with no `--channel` goes to.
|
|
174
|
+
*
|
|
175
|
+
* A channel qualifies when it is switched on AND has a destination configured —
|
|
176
|
+
* "enabled" alone is not enough, because an enabled surface with a blank
|
|
177
|
+
* destination is a channel that would throw at the provider rather than deliver.
|
|
178
|
+
*
|
|
179
|
+
* **Ambiguity refuses; it never picks.** With two qualifying channels there is
|
|
180
|
+
* no non-arbitrary winner, and this command has an outward effect: sending the
|
|
181
|
+
* owner's message to the wrong one of his channels is worse than printing the
|
|
182
|
+
* list and exiting non-zero. There is deliberately no priority order here to
|
|
183
|
+
* silently break that tie — a preference ordering baked into this file would be
|
|
184
|
+
* an invisible decision about where his messages go.
|
|
185
|
+
*/
|
|
186
|
+
export function resolveDefaultChannel(config: Pick<ConfigManager, 'get'>): DefaultChannelResolution {
|
|
187
|
+
const readiness = readChannelReadiness(config);
|
|
188
|
+
const qualifying = readiness.filter((entry) => entry.enabled && entry.destination !== null);
|
|
189
|
+
if (qualifying.length === 1) {
|
|
190
|
+
const only = qualifying[0]!;
|
|
191
|
+
return {
|
|
192
|
+
kind: 'resolved',
|
|
193
|
+
channel: only.channel,
|
|
194
|
+
destination: only.destination!,
|
|
195
|
+
reason: 'the only channel that is switched on and has a destination configured',
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
if (qualifying.length === 0) return { kind: 'none', candidates: readiness };
|
|
199
|
+
return { kind: 'ambiguous', candidates: qualifying };
|
|
200
|
+
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command.ts — `goodvibes-daemon send`, the shell's way to put a message on one
|
|
3
|
+
* of the owner's channels.
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* Nothing on this machine could send the owner a message from a script. The
|
|
8
|
+
* three binaries exposed no send verb, the daemon's HTTP API answers
|
|
9
|
+
* `401 AUTH_REQUIRED` to the operator token as stored, and driving the agent to
|
|
10
|
+
* do it failed with `Missing Telegram bot token` — because the credential lives
|
|
11
|
+
* in the DAEMON tier (`~/.goodvibes/daemon/settings.json`) and the agent was
|
|
12
|
+
* reading its own surface silo. So the one process that could always send was
|
|
13
|
+
* the daemon, and only while it was running.
|
|
14
|
+
*
|
|
15
|
+
* ## Three properties this command is built around
|
|
16
|
+
*
|
|
17
|
+
* 1. **It uses the delivery path, it is not a second sender.** Everything below
|
|
18
|
+
* ends in `ChannelDeliveryRouter.deliver()` — the same call
|
|
19
|
+
* `AutomationDeliveryManager.sendTarget` makes, reaching the same
|
|
20
|
+
* per-surface strategies in `strategies-core.ts`. Nothing here talks to a
|
|
21
|
+
* provider API directly.
|
|
22
|
+
*
|
|
23
|
+
* 2. **It never exits 0 on a send that did not happen.** The router throws with
|
|
24
|
+
* the provider's own error text and that error is printed and exits non-zero.
|
|
25
|
+
* `AutomationDeliveryManager.deliverText` was the alternative entry point and
|
|
26
|
+
* was NOT used, deliberately: it returns an empty array when a feature gate
|
|
27
|
+
* is off and returns failed attempts rather than throwing, so a caller that
|
|
28
|
+
* did not inspect its result would report success for a message that never
|
|
29
|
+
* left the machine — the exact false-green this command exists to avoid. The
|
|
30
|
+
* gate check it would have done is done here instead, explicitly, and a gate
|
|
31
|
+
* that is off produces a refusal naming the settings key rather than silence.
|
|
32
|
+
*
|
|
33
|
+
* 3. **Every message it sends arrives as literal text.** The body is passed
|
|
34
|
+
* through `inertBodyFor` for the target surface before it reaches the router,
|
|
35
|
+
* and there is no flag, env var or code path that skips that. The message
|
|
36
|
+
* normally comes from the operator's own shell, but the command must not
|
|
37
|
+
* become the way something else's text — a log line, a captured error, a
|
|
38
|
+
* remote agent's output piped in — arrives on the owner's phone rendered as
|
|
39
|
+
* live markup with a clickable link in it. See inert-text.ts.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import type { ConfigManager } from '../../config/index.ts';
|
|
43
|
+
import type { ChannelDeliveryRequest } from '@pellux/goodvibes-sdk/platform/channels';
|
|
44
|
+
import { getMissingSurfaceFeatureFlags, surfaceFeatureGateSettingsKeys } from '../../runtime/surface-feature-flags.ts';
|
|
45
|
+
import { findSendChannel, readChannelReadiness, resolveDefaultChannel, SEND_CHANNELS, type SendChannel } from './channels.ts';
|
|
46
|
+
import { describeSendFailure } from './failure-text.ts';
|
|
47
|
+
import { inertBodyFor } from './inert-text.ts';
|
|
48
|
+
|
|
49
|
+
/** What the router needs to actually send; injected so tests never reach the network. */
|
|
50
|
+
export type SendDeliver = (request: ChannelDeliveryRequest) => Promise<string | undefined>;
|
|
51
|
+
|
|
52
|
+
export interface SendCommandDeps {
|
|
53
|
+
/** Built with a `homeDir` so the daemon tier overlays — see channels.ts. */
|
|
54
|
+
readonly configManager: Pick<ConfigManager, 'get'>;
|
|
55
|
+
readonly deliver: SendDeliver;
|
|
56
|
+
/** Reads the whole of stdin; only called when no message argument was given. */
|
|
57
|
+
readonly readStdin: () => Promise<string>;
|
|
58
|
+
/** Whether stdin is a terminal. A TTY means there is no piped message to wait for. */
|
|
59
|
+
readonly stdinIsTty: boolean;
|
|
60
|
+
/** Injected so the run id in the delivery request is deterministic under test. */
|
|
61
|
+
readonly newRunId?: (() => string) | undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SendCommandResult {
|
|
65
|
+
readonly lines: readonly string[];
|
|
66
|
+
readonly exitCode: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ParsedSendArgs {
|
|
70
|
+
readonly channel: string | null;
|
|
71
|
+
readonly to: string | null;
|
|
72
|
+
readonly title: string | null;
|
|
73
|
+
readonly list: boolean;
|
|
74
|
+
readonly help: boolean;
|
|
75
|
+
readonly words: readonly string[];
|
|
76
|
+
readonly errors: readonly string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const USAGE = [
|
|
80
|
+
'Usage: goodvibes-daemon send [OPTIONS] [MESSAGE...]',
|
|
81
|
+
'',
|
|
82
|
+
'Send a message to one of your configured channels. With no MESSAGE, the',
|
|
83
|
+
'message is read from stdin, so this composes with other tooling.',
|
|
84
|
+
'',
|
|
85
|
+
'Options:',
|
|
86
|
+
' --channel <id> Channel to send to. With none named, your configured',
|
|
87
|
+
' channel is used and the output says which one.',
|
|
88
|
+
' --to <address> Where within that channel: an ntfy topic, a Telegram chat',
|
|
89
|
+
' id, a Slack or Mattermost channel id, a Matrix room id.',
|
|
90
|
+
' Without it the channel\'s configured destination is used.',
|
|
91
|
+
' --title <text> Title for channels that show one (ntfy).',
|
|
92
|
+
' --list Show every channel, whether it is on, and where it sends.',
|
|
93
|
+
' -h, --help Print this help',
|
|
94
|
+
'',
|
|
95
|
+
'A channel that is switched off is refused by name and NOTHING is sent — the',
|
|
96
|
+
'command never quietly falls back to the default, so a message meant for a',
|
|
97
|
+
'quiet channel cannot end up on a noisy one.',
|
|
98
|
+
'',
|
|
99
|
+
'The message is always delivered as literal text: markup a channel would',
|
|
100
|
+
'otherwise render — a Discord masked link, a Slack mention — arrives inert.',
|
|
101
|
+
].join('\n');
|
|
102
|
+
|
|
103
|
+
function parseSendArgs(argv: readonly string[]): ParsedSendArgs {
|
|
104
|
+
let channel: string | null = null;
|
|
105
|
+
let to: string | null = null;
|
|
106
|
+
let title: string | null = null;
|
|
107
|
+
let list = false;
|
|
108
|
+
let help = false;
|
|
109
|
+
const words: string[] = [];
|
|
110
|
+
const errors: string[] = [];
|
|
111
|
+
let optionsEnded = false;
|
|
112
|
+
|
|
113
|
+
const takeValue = (flag: string, value: string | undefined): string | null => {
|
|
114
|
+
if (value === undefined || value.length === 0) {
|
|
115
|
+
errors.push(`${flag} needs a value.`);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return value;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
122
|
+
const arg = argv[index]!;
|
|
123
|
+
if (optionsEnded) { words.push(arg); continue; }
|
|
124
|
+
// Everything after `--` is message text, so a message that starts with a
|
|
125
|
+
// dash is still sendable.
|
|
126
|
+
if (arg === '--') { optionsEnded = true; continue; }
|
|
127
|
+
if (arg === '-h' || arg === '--help') { help = true; continue; }
|
|
128
|
+
if (arg === '--list') { list = true; continue; }
|
|
129
|
+
if (arg === '--channel' || arg === '-c') { channel = takeValue(arg, argv[++index]); continue; }
|
|
130
|
+
if (arg.startsWith('--channel=')) { channel = takeValue('--channel', arg.slice('--channel='.length)); continue; }
|
|
131
|
+
if (arg === '--to') { to = takeValue(arg, argv[++index]); continue; }
|
|
132
|
+
if (arg.startsWith('--to=')) { to = takeValue('--to', arg.slice('--to='.length)); continue; }
|
|
133
|
+
if (arg === '--title') { title = takeValue(arg, argv[++index]); continue; }
|
|
134
|
+
if (arg.startsWith('--title=')) { title = takeValue('--title', arg.slice('--title='.length)); continue; }
|
|
135
|
+
if (arg.startsWith('-') && arg.length > 1) {
|
|
136
|
+
errors.push(`Unknown option: ${arg}`);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
words.push(arg);
|
|
140
|
+
}
|
|
141
|
+
return { channel, to, title, list, help, words, errors };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The channels that are actually usable right now, named. Every refusal ends
|
|
146
|
+
* with this: telling someone their channel is not configured without telling
|
|
147
|
+
* them which ones are just moves the guessing to them.
|
|
148
|
+
*/
|
|
149
|
+
function describeConfiguredChannels(config: Pick<ConfigManager, 'get'>): string {
|
|
150
|
+
const usable = readChannelReadiness(config)
|
|
151
|
+
.filter((entry) => entry.enabled && entry.destination !== null)
|
|
152
|
+
.map((entry) => entry.channel.id);
|
|
153
|
+
return usable.length > 0
|
|
154
|
+
? `Configured and ready: ${usable.join(', ')}.`
|
|
155
|
+
: 'No channel is currently both switched on and given a destination — run: goodvibes-daemon send --list';
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderChannelList(config: Pick<ConfigManager, 'get'>): string[] {
|
|
159
|
+
const readiness = readChannelReadiness(config);
|
|
160
|
+
const idWidth = Math.max(...SEND_CHANNELS.map((channel) => channel.id.length), 7);
|
|
161
|
+
const lines = ['Channels goodvibes-daemon send can reach:', ''];
|
|
162
|
+
for (const entry of readiness) {
|
|
163
|
+
const state = entry.enabled ? 'on ' : 'off';
|
|
164
|
+
const destination = entry.destination ?? `not set (${entry.channel.destinationKey})`;
|
|
165
|
+
lines.push(` ${entry.channel.id.padEnd(idWidth)} ${state} ${entry.channel.addressLabel}: ${destination}`);
|
|
166
|
+
}
|
|
167
|
+
lines.push('');
|
|
168
|
+
lines.push('Override any channel\'s destination for one message with --to <address>.');
|
|
169
|
+
lines.push('');
|
|
170
|
+
const resolution = resolveDefaultChannel(config);
|
|
171
|
+
if (resolution.kind === 'resolved') {
|
|
172
|
+
lines.push(`Default with no --channel: ${resolution.channel.id} (${resolution.reason}).`);
|
|
173
|
+
} else if (resolution.kind === 'none') {
|
|
174
|
+
lines.push('There is no default: no channel is both switched on and given a destination.');
|
|
175
|
+
} else {
|
|
176
|
+
const names = resolution.candidates.map((entry) => entry.channel.id).join(', ');
|
|
177
|
+
lines.push(`There is no default: ${names} all qualify, so --channel is required.`);
|
|
178
|
+
}
|
|
179
|
+
return lines;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Refusals that must happen before anything is sent, in the order they matter. */
|
|
183
|
+
function checkChannelUsable(
|
|
184
|
+
config: Pick<ConfigManager, 'get'>,
|
|
185
|
+
channel: SendChannel,
|
|
186
|
+
): readonly string[] {
|
|
187
|
+
if (config.get(channel.enabledKey) !== true) {
|
|
188
|
+
// No fallback to the default channel, deliberately. Falling back is how a
|
|
189
|
+
// message meant for a quiet channel lands on a noisy one, and how a caller
|
|
190
|
+
// that named a channel because it mattered never finds out it was ignored.
|
|
191
|
+
return [
|
|
192
|
+
`${channel.label} is switched off, so nothing was sent.`,
|
|
193
|
+
`Switch it on with: goodvibes surfaces enable ${channel.id} (settings key ${channel.enabledKey})`,
|
|
194
|
+
describeConfiguredChannels(config),
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
// The same capability gates the daemon's own delivery honours. Checked here
|
|
198
|
+
// rather than left to AutomationDeliveryManager, which answers a disabled
|
|
199
|
+
// gate with an empty result and no reason.
|
|
200
|
+
const missing = getMissingSurfaceFeatureFlags(config, channel.id);
|
|
201
|
+
if (missing.length > 0) {
|
|
202
|
+
return [
|
|
203
|
+
`${channel.label} is switched on but the capabilities delivery needs are off, so nothing was sent.`,
|
|
204
|
+
`Turn these settings on: ${surfaceFeatureGateSettingsKeys(missing).join(', ')}`,
|
|
205
|
+
describeConfiguredChannels(config),
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function runSendCommand(
|
|
212
|
+
argv: readonly string[],
|
|
213
|
+
deps: SendCommandDeps,
|
|
214
|
+
): Promise<SendCommandResult> {
|
|
215
|
+
const parsed = parseSendArgs(argv);
|
|
216
|
+
if (parsed.errors.length > 0) {
|
|
217
|
+
return { lines: [...parsed.errors, '', USAGE], exitCode: 2 };
|
|
218
|
+
}
|
|
219
|
+
if (parsed.help) return { lines: [USAGE], exitCode: 0 };
|
|
220
|
+
if (parsed.list) return { lines: renderChannelList(deps.configManager), exitCode: 0 };
|
|
221
|
+
|
|
222
|
+
// --- which channel -------------------------------------------------------
|
|
223
|
+
let channel: SendChannel;
|
|
224
|
+
let usedDefault = false;
|
|
225
|
+
let defaultReason = '';
|
|
226
|
+
if (parsed.channel !== null) {
|
|
227
|
+
const named = findSendChannel(parsed.channel);
|
|
228
|
+
if (!named) {
|
|
229
|
+
return {
|
|
230
|
+
lines: [
|
|
231
|
+
`Unknown channel: ${parsed.channel}`,
|
|
232
|
+
`Known channels: ${SEND_CHANNELS.map((entry) => entry.id).join(', ')}`,
|
|
233
|
+
describeConfiguredChannels(deps.configManager),
|
|
234
|
+
],
|
|
235
|
+
exitCode: 2,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
channel = named;
|
|
239
|
+
} else {
|
|
240
|
+
const resolution = resolveDefaultChannel(deps.configManager);
|
|
241
|
+
if (resolution.kind === 'none') {
|
|
242
|
+
return {
|
|
243
|
+
lines: [
|
|
244
|
+
'No channel was named and none is configured to be the default, so nothing was sent.',
|
|
245
|
+
'A channel qualifies when it is switched on AND has a destination set.',
|
|
246
|
+
'',
|
|
247
|
+
...renderChannelList(deps.configManager),
|
|
248
|
+
],
|
|
249
|
+
exitCode: 2,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
if (resolution.kind === 'ambiguous') {
|
|
253
|
+
return {
|
|
254
|
+
lines: [
|
|
255
|
+
`No channel was named and more than one qualifies (${resolution.candidates.map((entry) => entry.channel.id).join(', ')}), so nothing was sent.`,
|
|
256
|
+
'Name one with --channel <id> rather than have this command guess which of your channels to message.',
|
|
257
|
+
],
|
|
258
|
+
exitCode: 2,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
channel = resolution.channel;
|
|
262
|
+
usedDefault = true;
|
|
263
|
+
defaultReason = resolution.reason;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// --- what to send --------------------------------------------------------
|
|
267
|
+
let message = parsed.words.join(' ');
|
|
268
|
+
if (message.trim().length === 0) {
|
|
269
|
+
if (deps.stdinIsTty) {
|
|
270
|
+
return {
|
|
271
|
+
lines: ['No message given. Pass it as an argument or pipe it on stdin.', '', USAGE],
|
|
272
|
+
exitCode: 2,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
message = (await deps.readStdin()).replace(/\n+$/, '');
|
|
276
|
+
}
|
|
277
|
+
if (message.trim().length === 0) {
|
|
278
|
+
return { lines: ['The message was empty, so nothing was sent.'], exitCode: 2 };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const refusal = checkChannelUsable(deps.configManager, channel);
|
|
282
|
+
if (refusal.length > 0) return { lines: refusal, exitCode: 1 };
|
|
283
|
+
|
|
284
|
+
// --- send ----------------------------------------------------------------
|
|
285
|
+
const runId = deps.newRunId?.() ?? `cli-send-${Date.now().toString(36)}`;
|
|
286
|
+
const title = parsed.title ?? 'GoodVibes';
|
|
287
|
+
const body = inertBodyFor(channel.surfaceKind, message);
|
|
288
|
+
const request: ChannelDeliveryRequest = {
|
|
289
|
+
target: {
|
|
290
|
+
kind: 'surface',
|
|
291
|
+
surfaceKind: channel.surfaceKind,
|
|
292
|
+
...(parsed.to === null ? {} : { address: parsed.to }),
|
|
293
|
+
label: title,
|
|
294
|
+
},
|
|
295
|
+
body,
|
|
296
|
+
title,
|
|
297
|
+
jobId: 'goodvibes-daemon-send',
|
|
298
|
+
runId,
|
|
299
|
+
// A message typed at a shell has no artifacts and no control-plane session
|
|
300
|
+
// to link back to; a link appended here would point at a page the reader
|
|
301
|
+
// did not ask for.
|
|
302
|
+
includeLinks: false,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
const preamble = usedDefault
|
|
306
|
+
? [`No channel named — using ${channel.id}: ${defaultReason}.`]
|
|
307
|
+
: [];
|
|
308
|
+
try {
|
|
309
|
+
const responseId = await deps.deliver(request);
|
|
310
|
+
return {
|
|
311
|
+
lines: [
|
|
312
|
+
...preamble,
|
|
313
|
+
`Sent to ${channel.label}${responseId ? ` (${responseId})` : ''}.`,
|
|
314
|
+
],
|
|
315
|
+
exitCode: 0,
|
|
316
|
+
};
|
|
317
|
+
} catch (error) {
|
|
318
|
+
// The provider's own words, not a paraphrase: "Missing Telegram chat id"
|
|
319
|
+
// and "HTTP 401: Unauthorized" are different problems with different fixes,
|
|
320
|
+
// and flattening them into "delivery failed" is what makes a failed send
|
|
321
|
+
// take an hour to diagnose. See failure-text.ts for what is stripped
|
|
322
|
+
// (credentials) and what is deliberately kept (everything else).
|
|
323
|
+
return {
|
|
324
|
+
lines: [
|
|
325
|
+
...preamble,
|
|
326
|
+
`Sending to ${channel.label} failed, so the message did NOT go out.`,
|
|
327
|
+
describeSendFailure(error),
|
|
328
|
+
],
|
|
329
|
+
exitCode: 1,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
}
|