talon-agent 4.0.1 → 4.2.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/README.md +1 -1
- package/package.json +1 -1
- package/src/core/engine/gateway-actions/cross-send.ts +133 -21
- package/src/core/engine/gateway-actions/index.ts +6 -0
- package/src/core/engine/gateway-actions/whatsapp-account.ts +54 -0
- package/src/core/tools/cross-send.ts +37 -2
- package/src/core/tools/index.ts +2 -0
- package/src/core/tools/whatsapp.ts +109 -0
- package/src/frontend/whatsapp/actions/account.ts +282 -0
- package/src/frontend/whatsapp/actions/chat-info.ts +2 -9
- package/src/frontend/whatsapp/actions/index.ts +13 -2
- package/src/frontend/whatsapp/actions/shared.ts +14 -0
package/README.md
CHANGED
|
@@ -247,7 +247,7 @@ The `native` frontend turns the daemon into a **client bridge** — a versioned
|
|
|
247
247
|
- **Remote (mobile/LAN):** point the app at `host:port` + token; the bridge requires `Authorization: Bearer …` (or `?token=` on the SSE stream) whenever a token is set.
|
|
248
248
|
- **Encryption:** off-loopback binds serve **HTTPS by default** with a persistent self-signed certificate (`~/.talon/keys/`); the companion pins its SHA-256 fingerprint on first connect and refuses any change afterwards. The daemon logs the fingerprint at startup and `/health` advertises it. Opt out (or in, on loopback) with `"tls": false` / `true` in the `native` section.
|
|
249
249
|
|
|
250
|
-
The app provides multi-chat history, live streaming with reasoning + tool-call visibility, per-chat model/effort/reset, and **settings sync** — read and change the daemon's own config (default model, display name, timezone, pulse/heartbeat/dream) and restart it. See [apps/companion/README.md](apps/companion/README.md).
|
|
250
|
+
The app **keeps itself up to date** — it watches this repo's releases and installs the next one itself (silently on a rooted/Shizuku phone, swap-and-relaunch on desktop; see [docs/companion-updates.md](docs/companion-updates.md)). It provides multi-chat history, live streaming with reasoning + tool-call visibility, per-chat model/effort/reset, and **settings sync** — read and change the daemon's own config (default model, display name, timezone, pulse/heartbeat/dream) and restart it. See [apps/companion/README.md](apps/companion/README.md).
|
|
251
251
|
|
|
252
252
|
---
|
|
253
253
|
|
package/package.json
CHANGED
|
@@ -2,22 +2,27 @@
|
|
|
2
2
|
* Cross-frontend send — the server side of the `send_via` tool.
|
|
3
3
|
*
|
|
4
4
|
* A chat-free shared action: any session (an active Telegram chat, the
|
|
5
|
-
* heartbeat, a dream run) delivers a
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
5
|
+
* heartbeat, a dream run) delivers a message through ANY enabled
|
|
6
|
+
* messaging frontend by naming it explicitly. Explicit is the point —
|
|
7
|
+
* the gateway's normal chat_id routing infers the owning frontend from
|
|
8
|
+
* the numeric id's shape, and WhatsApp's hash-derived ids also match the
|
|
9
|
+
* Telegram matcher, so a cross-frontend send routed that way lands on
|
|
10
|
+
* the wrong platform once the originating turn's context is cleared.
|
|
11
|
+
*
|
|
12
|
+
* Text is the common case; a media source (file_path / url / file_id)
|
|
13
|
+
* promotes the send to the matching per-kind action (send_photo,
|
|
14
|
+
* send_file, …), which every messaging frontend already implements and
|
|
15
|
+
* already resolves `body.target` for. Without this, reaching another
|
|
16
|
+
* platform meant text only — you could describe a photo to someone on
|
|
17
|
+
* WhatsApp but not send them one.
|
|
12
18
|
*
|
|
13
19
|
* Core never imports src/frontend (dependency-cruiser enforces it), so
|
|
14
20
|
* dispatch goes through a broker: `Gateway.registerFrontendHandler`
|
|
15
21
|
* mirrors each frontend's action handler here, and the action calls the
|
|
16
|
-
* target's own
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* ride in `body.target` for the frontend's adapter to resolve.
|
|
22
|
+
* target's own send action. A numeric target doubles as the handler's
|
|
23
|
+
* chatId key, so id-addressed frontends (telegram, discord, teams,
|
|
24
|
+
* native) need no changes; non-numeric forms (WhatsApp phone numbers,
|
|
25
|
+
* wa_* ids) ride in `body.target` for the frontend's adapter to resolve.
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
28
|
import type { FrontendActionHandler } from "../../types.js";
|
|
@@ -38,6 +43,77 @@ export function registerCrossSendTarget(
|
|
|
38
43
|
else targets.set(name, handler);
|
|
39
44
|
}
|
|
40
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Media kinds send_via can carry. Each maps to `send_<kind>`, the action
|
|
48
|
+
* name every frontend's media handler already registers.
|
|
49
|
+
*/
|
|
50
|
+
const MEDIA_KINDS = [
|
|
51
|
+
"photo",
|
|
52
|
+
"video",
|
|
53
|
+
"animation",
|
|
54
|
+
"voice",
|
|
55
|
+
"audio",
|
|
56
|
+
"sticker",
|
|
57
|
+
"video_note",
|
|
58
|
+
"file",
|
|
59
|
+
] as const;
|
|
60
|
+
|
|
61
|
+
const MEDIA_KIND_SET: ReadonlySet<string> = new Set(MEDIA_KINDS);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Extension → kind, so the common cases need no media_type. Anything
|
|
65
|
+
* unrecognised travels as a document, which is the one kind that
|
|
66
|
+
* accepts arbitrary bytes on every platform.
|
|
67
|
+
*/
|
|
68
|
+
const KIND_BY_EXT: Record<string, string> = {
|
|
69
|
+
".jpg": "photo",
|
|
70
|
+
".jpeg": "photo",
|
|
71
|
+
".png": "photo",
|
|
72
|
+
".webp": "photo",
|
|
73
|
+
".heic": "photo",
|
|
74
|
+
".gif": "animation",
|
|
75
|
+
".mp4": "video",
|
|
76
|
+
".mov": "video",
|
|
77
|
+
".mkv": "video",
|
|
78
|
+
".webm": "video",
|
|
79
|
+
".mp3": "audio",
|
|
80
|
+
".m4a": "audio",
|
|
81
|
+
".flac": "audio",
|
|
82
|
+
".wav": "audio",
|
|
83
|
+
".ogg": "voice",
|
|
84
|
+
".opus": "voice",
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
/** Lowercased extension of a path or URL, "" when there isn't one. */
|
|
88
|
+
function extensionOf(source: string): string {
|
|
89
|
+
let path = source;
|
|
90
|
+
try {
|
|
91
|
+
// Strip query/fragment so "a.png?v=2" still reads as .png.
|
|
92
|
+
path = new URL(source).pathname;
|
|
93
|
+
} catch {
|
|
94
|
+
// Not a URL — a filesystem path, used as-is.
|
|
95
|
+
}
|
|
96
|
+
const dot = path.lastIndexOf(".");
|
|
97
|
+
const slash = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\"));
|
|
98
|
+
return dot > slash ? path.slice(dot).toLowerCase() : "";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Look up a registered frontend handler by name. Exported for the other
|
|
103
|
+
* cross-frontend actions (account management), which need the same
|
|
104
|
+
* broker and the same "is it enabled?" answer.
|
|
105
|
+
*/
|
|
106
|
+
export function crossSendTarget(
|
|
107
|
+
name: string,
|
|
108
|
+
): FrontendActionHandler | undefined {
|
|
109
|
+
return targets.get(name);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Enabled frontend names, for a "not enabled (enabled: …)" error. */
|
|
113
|
+
export function crossSendTargetNames(): string[] {
|
|
114
|
+
return [...targets.keys()].sort();
|
|
115
|
+
}
|
|
116
|
+
|
|
41
117
|
export const crossSendHandlers: SharedActionHandlers = {
|
|
42
118
|
send_via: async (body) => {
|
|
43
119
|
const frontend = String(body.frontend ?? "")
|
|
@@ -45,35 +121,71 @@ export const crossSendHandlers: SharedActionHandlers = {
|
|
|
45
121
|
.toLowerCase();
|
|
46
122
|
const target = String(body.target ?? "").trim();
|
|
47
123
|
const text = String(body.text ?? "");
|
|
124
|
+
const filePath = body.file_path ? String(body.file_path) : "";
|
|
125
|
+
const url = body.url ? String(body.url) : "";
|
|
126
|
+
const fileId = body.file_id ? String(body.file_id) : "";
|
|
127
|
+
const hasMedia = Boolean(filePath || url || fileId);
|
|
48
128
|
if (!frontend) {
|
|
49
129
|
return { ok: false, error: "send_via: frontend is required" };
|
|
50
130
|
}
|
|
51
131
|
if (!target) {
|
|
52
132
|
return { ok: false, error: "send_via: target is required" };
|
|
53
133
|
}
|
|
54
|
-
if (!text.trim()) {
|
|
55
|
-
return {
|
|
134
|
+
if (!hasMedia && !text.trim()) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error:
|
|
138
|
+
"send_via: text is required (or a media source: file_path, url, " +
|
|
139
|
+
"or file_id)",
|
|
140
|
+
};
|
|
56
141
|
}
|
|
57
|
-
const handler =
|
|
142
|
+
const handler = crossSendTarget(frontend);
|
|
58
143
|
if (!handler) {
|
|
59
|
-
const enabled =
|
|
144
|
+
const enabled = crossSendTargetNames().join(", ") || "none";
|
|
60
145
|
return {
|
|
61
146
|
ok: false,
|
|
62
147
|
error: `send_via: the ${frontend} frontend is not enabled (enabled: ${enabled})`,
|
|
63
148
|
};
|
|
64
149
|
}
|
|
150
|
+
|
|
151
|
+
// Text-only keeps the exact shape it always had; a media source
|
|
152
|
+
// promotes the send to the per-kind action, with text as the caption.
|
|
153
|
+
let payload: Record<string, unknown>;
|
|
154
|
+
let action: string;
|
|
155
|
+
if (hasMedia) {
|
|
156
|
+
const explicit = String(body.media_type ?? "")
|
|
157
|
+
.trim()
|
|
158
|
+
.toLowerCase();
|
|
159
|
+
const kind =
|
|
160
|
+
explicit || KIND_BY_EXT[extensionOf(filePath || url)] || "file";
|
|
161
|
+
if (!MEDIA_KIND_SET.has(kind)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error:
|
|
165
|
+
`send_via: unknown media_type "${kind}" — use one of ` +
|
|
166
|
+
`${MEDIA_KINDS.join(", ")}`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
action = `send_${kind}`;
|
|
170
|
+
payload = { action, target };
|
|
171
|
+
if (filePath) payload.file_path = filePath;
|
|
172
|
+
if (url) payload.url = url;
|
|
173
|
+
if (fileId) payload.file_id = fileId;
|
|
174
|
+
if (text.trim()) payload.caption = text;
|
|
175
|
+
} else {
|
|
176
|
+
action = "send_message";
|
|
177
|
+
payload = { action, text, target };
|
|
178
|
+
}
|
|
179
|
+
|
|
65
180
|
// A numeric target is the handler's chatId key; other forms travel in
|
|
66
181
|
// body.target for the frontend's adapter to resolve. 0 is the same
|
|
67
182
|
// "no chat" sentinel the chat-free dispatch itself uses.
|
|
68
183
|
const numericTarget = /^-?\d+$/.test(target) ? Number(target) : 0;
|
|
69
|
-
const result = await handler(
|
|
70
|
-
{ action: "send_message", text, target },
|
|
71
|
-
numericTarget,
|
|
72
|
-
);
|
|
184
|
+
const result = await handler(payload, numericTarget);
|
|
73
185
|
if (!result) {
|
|
74
186
|
return {
|
|
75
187
|
ok: false,
|
|
76
|
-
error: `send_via: the ${frontend} frontend does not implement
|
|
188
|
+
error: `send_via: the ${frontend} frontend does not implement ${action}`,
|
|
77
189
|
};
|
|
78
190
|
}
|
|
79
191
|
return result;
|
|
@@ -37,6 +37,10 @@ import {
|
|
|
37
37
|
chatFreeActions as meshChatFreeActions,
|
|
38
38
|
} from "./mesh.js";
|
|
39
39
|
import { crossSendHandlers, crossSendChatFreeActions } from "./cross-send.js";
|
|
40
|
+
import {
|
|
41
|
+
whatsappAccountHandlers,
|
|
42
|
+
whatsappAccountChatFreeActions,
|
|
43
|
+
} from "./whatsapp-account.js";
|
|
40
44
|
import { nativeHandlers } from "./native.js";
|
|
41
45
|
|
|
42
46
|
// Null-prototype so a request `action` of "toString" / "constructor" / etc.
|
|
@@ -54,6 +58,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
54
58
|
...modelHandlers,
|
|
55
59
|
...meshHandlers,
|
|
56
60
|
...crossSendHandlers,
|
|
61
|
+
...whatsappAccountHandlers,
|
|
57
62
|
...nativeHandlers,
|
|
58
63
|
});
|
|
59
64
|
|
|
@@ -64,6 +69,7 @@ const handlers: SharedActionHandlers = Object.assign(Object.create(null), {
|
|
|
64
69
|
const chatFreeActions: ReadonlySet<string> = new Set([
|
|
65
70
|
...meshChatFreeActions,
|
|
66
71
|
...crossSendChatFreeActions,
|
|
72
|
+
...whatsappAccountChatFreeActions,
|
|
67
73
|
]);
|
|
68
74
|
|
|
69
75
|
export async function handleSharedAction(
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WhatsApp account management — the server side of the
|
|
3
|
+
* `whatsapp_account` tool.
|
|
4
|
+
*
|
|
5
|
+
* A chat-free shared action, for the same reason `send_via` is one: the
|
|
6
|
+
* account surface belongs to the WhatsApp *connection*, not to any
|
|
7
|
+
* conversation, and the session driving it usually lives somewhere else
|
|
8
|
+
* (Dylan's Telegram DM, or a heartbeat run with no ambient chat at
|
|
9
|
+
* all). Routing it through the normal chat_id path would demand a
|
|
10
|
+
* WhatsApp chat that need not exist.
|
|
11
|
+
*
|
|
12
|
+
* Dispatch reuses the cross-send broker — core never imports
|
|
13
|
+
* src/frontend, so the WhatsApp handler arrives by registration — and
|
|
14
|
+
* deliberately sends no `target`, because the frontend's dispatcher
|
|
15
|
+
* treats `body.target` as a chat-routing key and these ops address the
|
|
16
|
+
* logged-in identity instead.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { SharedActionHandlers } from "./types.js";
|
|
20
|
+
import { crossSendTarget, crossSendTargetNames } from "./cross-send.js";
|
|
21
|
+
|
|
22
|
+
export const whatsappAccountHandlers: SharedActionHandlers = {
|
|
23
|
+
whatsapp_account: async (body) => {
|
|
24
|
+
const op = String(body.op ?? "").trim();
|
|
25
|
+
if (!op) {
|
|
26
|
+
return { ok: false, error: "whatsapp_account: op is required" };
|
|
27
|
+
}
|
|
28
|
+
const handler = crossSendTarget("whatsapp");
|
|
29
|
+
if (!handler) {
|
|
30
|
+
const enabled = crossSendTargetNames().join(", ") || "none";
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: `whatsapp_account: the whatsapp frontend is not enabled (enabled: ${enabled})`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// 0 is the chat-free dispatch's own "no chat" sentinel; the frontend
|
|
37
|
+
// lists whatsapp_account as chatless, so it never resolves one.
|
|
38
|
+
const result = await handler({ ...body, action: "whatsapp_account" }, 0);
|
|
39
|
+
if (!result) {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error:
|
|
43
|
+
"whatsapp_account: the whatsapp frontend does not implement " +
|
|
44
|
+
"whatsapp_account (is it running an older build?)",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Chat-free by design — see the module note. */
|
|
52
|
+
export const whatsappAccountChatFreeActions: ReadonlySet<string> = new Set([
|
|
53
|
+
"whatsapp_account",
|
|
54
|
+
]);
|
|
@@ -14,14 +14,17 @@ import type { ToolDefinition } from "./types.js";
|
|
|
14
14
|
export const crossSendTools: ToolDefinition[] = [
|
|
15
15
|
{
|
|
16
16
|
name: "send_via",
|
|
17
|
-
description: `Send a
|
|
17
|
+
description: `Send a message — text, or a photo/video/file/voice note — through ANY enabled messaging frontend, including one this chat does not live on. Use it to reach someone on another platform (e.g. from Telegram or a heartbeat run, message a WhatsApp number). For the current chat, keep using the normal delivery tools (end_turn / send / send_message).
|
|
18
18
|
|
|
19
19
|
Target forms:
|
|
20
20
|
- whatsapp: a phone number with country code ("+353871234567" or "353871234567"), a wa_dm_<number>/wa_group_<id> chat id, or a raw JID; a bare numeric chat id works only for chats already seen since startup
|
|
21
21
|
- telegram / discord / teams / native: the numeric chat id, as a string (Telegram supergroups/channels are negative, DMs positive)
|
|
22
22
|
|
|
23
|
+
Media: give file_path (a workspace file) or url (a public URL, fetched by the platform); text becomes the caption. The kind is inferred from the extension — pass media_type to override (e.g. send a .webp as a sticker rather than a photo). file_id is Telegram-only; other platforms have no equivalent and will say so.
|
|
24
|
+
|
|
23
25
|
Examples:
|
|
24
26
|
send_via(frontend="whatsapp", target="+353871234567", text="On my way")
|
|
27
|
+
send_via(frontend="whatsapp", target="+353871234567", file_path="media/plot.png", text="here's the chart")
|
|
25
28
|
send_via(frontend="telegram", target="-1001426819337", text="Build is green")
|
|
26
29
|
|
|
27
30
|
Fails with a clear error when the named frontend is not enabled or not connected.`,
|
|
@@ -36,8 +39,40 @@ Fails with a clear error when the named frontend is not enabled or not connected
|
|
|
36
39
|
),
|
|
37
40
|
text: z
|
|
38
41
|
.string()
|
|
42
|
+
.optional()
|
|
43
|
+
.describe(
|
|
44
|
+
"Message text, or the caption when media is attached. Markdown supported where the platform supports it. Required unless a media source is given.",
|
|
45
|
+
),
|
|
46
|
+
file_path: z
|
|
47
|
+
.string()
|
|
48
|
+
.optional()
|
|
49
|
+
.describe("Workspace file to send as media."),
|
|
50
|
+
url: z
|
|
51
|
+
.string()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe(
|
|
54
|
+
"Public URL of media to send — fetched by the target platform.",
|
|
55
|
+
),
|
|
56
|
+
file_id: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
"Platform file_id for media already seen in chat (Telegram only).",
|
|
61
|
+
),
|
|
62
|
+
media_type: z
|
|
63
|
+
.enum([
|
|
64
|
+
"photo",
|
|
65
|
+
"video",
|
|
66
|
+
"animation",
|
|
67
|
+
"voice",
|
|
68
|
+
"audio",
|
|
69
|
+
"sticker",
|
|
70
|
+
"video_note",
|
|
71
|
+
"file",
|
|
72
|
+
])
|
|
73
|
+
.optional()
|
|
39
74
|
.describe(
|
|
40
|
-
"
|
|
75
|
+
"Override the media kind. Default: inferred from the file extension, falling back to a document.",
|
|
41
76
|
),
|
|
42
77
|
},
|
|
43
78
|
execute: (params, bridge) => bridge("send_via", params),
|
package/src/core/tools/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { adminTools } from "./admin.js";
|
|
|
23
23
|
import { modelTools } from "./models.js";
|
|
24
24
|
import { meshTools } from "./mesh.js";
|
|
25
25
|
import { crossSendTools } from "./cross-send.js";
|
|
26
|
+
import { whatsappTools } from "./whatsapp.js";
|
|
26
27
|
import { moderationTools } from "./moderation.js";
|
|
27
28
|
import { nativeTools } from "./native.js";
|
|
28
29
|
|
|
@@ -44,6 +45,7 @@ export const ALL_TOOLS: readonly ToolDefinition[] = [
|
|
|
44
45
|
...modelTools,
|
|
45
46
|
...meshTools,
|
|
46
47
|
...crossSendTools,
|
|
48
|
+
...whatsappTools,
|
|
47
49
|
...moderationTools,
|
|
48
50
|
];
|
|
49
51
|
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { ToolDefinition } from "./types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* WhatsApp account management. Deliberately NOT frontend-restricted
|
|
6
|
+
* (the same reasoning as crossSendTools): the account being managed and
|
|
7
|
+
* the session managing it are usually on different frontends — a
|
|
8
|
+
* Telegram DM setting the WhatsApp profile photo, or a heartbeat run
|
|
9
|
+
* with no ambient chat. The gateway serves it as a chat-free action
|
|
10
|
+
* (see engine/gateway-actions/whatsapp-account.ts), so it needs no
|
|
11
|
+
* resolved WhatsApp conversation to work.
|
|
12
|
+
*/
|
|
13
|
+
export const whatsappTools: ToolDefinition[] = [
|
|
14
|
+
{
|
|
15
|
+
name: "whatsapp_account",
|
|
16
|
+
description: `Read and change the bot's own WhatsApp account — profile, privacy, blocklist, presence. Works from any frontend (it addresses the WhatsApp connection, not a chat), and fails with a clear error when the WhatsApp frontend is not enabled.
|
|
17
|
+
|
|
18
|
+
Profile:
|
|
19
|
+
whatsapp_account(op="get_profile") — number, display name, about text, profile photo URL
|
|
20
|
+
whatsapp_account(op="set_name", name="Claudius")
|
|
21
|
+
whatsapp_account(op="set_about", text="Running on Talon") — empty text clears it
|
|
22
|
+
whatsapp_account(op="set_photo", file_path="media/avatar.jpg") — or url=...; square images work best
|
|
23
|
+
whatsapp_account(op="remove_photo")
|
|
24
|
+
|
|
25
|
+
Other people:
|
|
26
|
+
whatsapp_account(op="get_user_profile", contact="+353871234567") — their about + photo, as your account can see them
|
|
27
|
+
|
|
28
|
+
Privacy (op="get_privacy" lists the current values):
|
|
29
|
+
whatsapp_account(op="set_privacy", setting="last_seen", value="contacts")
|
|
30
|
+
settings: last_seen, online, profile_photo, about, groups_add (all | contacts | contact_blacklist | none¹),
|
|
31
|
+
read_receipts (all | none), calls (all | known), messages (all | contacts),
|
|
32
|
+
online also accepts match_last_seen
|
|
33
|
+
¹ groups_add has no "none"; online takes only all/match_last_seen.
|
|
34
|
+
whatsapp_account(op="set_disappearing", duration="7d") — default timer for NEW chats: off, 24h, 7d, 90d, or seconds
|
|
35
|
+
|
|
36
|
+
Blocking and presence:
|
|
37
|
+
whatsapp_account(op="get_blocklist")
|
|
38
|
+
whatsapp_account(op="block", contact="+353871234567") / op="unblock"
|
|
39
|
+
whatsapp_account(op="set_presence", presence="available") — or "unavailable" to appear offline
|
|
40
|
+
|
|
41
|
+
Changes here are real and immediately visible to that account's contacts.`,
|
|
42
|
+
schema: {
|
|
43
|
+
op: z
|
|
44
|
+
.enum([
|
|
45
|
+
"get_profile",
|
|
46
|
+
"get_user_profile",
|
|
47
|
+
"set_name",
|
|
48
|
+
"set_about",
|
|
49
|
+
"set_photo",
|
|
50
|
+
"remove_photo",
|
|
51
|
+
"get_privacy",
|
|
52
|
+
"set_privacy",
|
|
53
|
+
"set_disappearing",
|
|
54
|
+
"get_blocklist",
|
|
55
|
+
"block",
|
|
56
|
+
"unblock",
|
|
57
|
+
"set_presence",
|
|
58
|
+
])
|
|
59
|
+
.describe("The account operation to perform."),
|
|
60
|
+
name: z.string().optional().describe("New display name (set_name)."),
|
|
61
|
+
text: z
|
|
62
|
+
.string()
|
|
63
|
+
.optional()
|
|
64
|
+
.describe("New about/status text (set_about); empty clears it."),
|
|
65
|
+
file_path: z
|
|
66
|
+
.string()
|
|
67
|
+
.optional()
|
|
68
|
+
.describe("Workspace image for set_photo."),
|
|
69
|
+
url: z.string().optional().describe("Public image URL for set_photo."),
|
|
70
|
+
contact: z
|
|
71
|
+
.string()
|
|
72
|
+
.optional()
|
|
73
|
+
.describe(
|
|
74
|
+
"Who to act on for get_user_profile / block / unblock: a phone number with country code, or a JID. Not an internal id — deliberately not named user_id.",
|
|
75
|
+
),
|
|
76
|
+
setting: z
|
|
77
|
+
.enum([
|
|
78
|
+
"last_seen",
|
|
79
|
+
"online",
|
|
80
|
+
"profile_photo",
|
|
81
|
+
"about",
|
|
82
|
+
"read_receipts",
|
|
83
|
+
"groups_add",
|
|
84
|
+
"calls",
|
|
85
|
+
"messages",
|
|
86
|
+
])
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Which privacy knob to change (set_privacy)."),
|
|
89
|
+
value: z
|
|
90
|
+
.string()
|
|
91
|
+
.optional()
|
|
92
|
+
.describe(
|
|
93
|
+
"New privacy value (set_privacy) — the accepted set depends on the setting.",
|
|
94
|
+
),
|
|
95
|
+
duration: z
|
|
96
|
+
.string()
|
|
97
|
+
.optional()
|
|
98
|
+
.describe(
|
|
99
|
+
"Default disappearing timer for new chats (set_disappearing): off, 24h, 7d, 90d, or seconds.",
|
|
100
|
+
),
|
|
101
|
+
presence: z
|
|
102
|
+
.enum(["available", "unavailable"])
|
|
103
|
+
.optional()
|
|
104
|
+
.describe("Presence to broadcast (set_presence)."),
|
|
105
|
+
},
|
|
106
|
+
execute: (params, bridge) => bridge("whatsapp_account", params),
|
|
107
|
+
tag: "admin",
|
|
108
|
+
},
|
|
109
|
+
];
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Account actions — the bot's own WhatsApp identity and settings.
|
|
3
|
+
*
|
|
4
|
+
* Everything here addresses the logged-in account rather than a chat:
|
|
5
|
+
* display name, about text, profile photo, privacy knobs, the
|
|
6
|
+
* blocklist, and presence. That makes these the only WhatsApp actions
|
|
7
|
+
* with no chat to resolve, so the dispatcher treats them as chatless —
|
|
8
|
+
* which is also what lets them be driven from another frontend (a
|
|
9
|
+
* Telegram session managing the WhatsApp account) with no ambient
|
|
10
|
+
* WhatsApp chat in play.
|
|
11
|
+
*
|
|
12
|
+
* One `whatsapp_account` action with an `op` switch, mirroring
|
|
13
|
+
* `moderate`: the surface is wide but every op is small, and a single
|
|
14
|
+
* tool keeps the model from having to learn a dozen names.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { jidNormalizedUser, type WASocket } from "baileys";
|
|
18
|
+
import type { ActionResult } from "../../../core/types.js";
|
|
19
|
+
import {
|
|
20
|
+
readStatusText,
|
|
21
|
+
resolveMediaUpload,
|
|
22
|
+
toUserJid,
|
|
23
|
+
tryAction,
|
|
24
|
+
} from "./shared.js";
|
|
25
|
+
import type { WhatsAppActionContext, WhatsAppActionHandlers } from "./types.js";
|
|
26
|
+
|
|
27
|
+
/** The account's own JID, device suffix stripped. */
|
|
28
|
+
function ownJid(sock: WASocket): string {
|
|
29
|
+
const raw = sock.user?.id;
|
|
30
|
+
if (!raw) throw new Error("not logged in — no account JID yet");
|
|
31
|
+
return jidNormalizedUser(raw);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The privacy knobs WhatsApp exposes, each with its accepted values and
|
|
36
|
+
* the setter it maps to. Table-driven so `set_privacy` stays one lookup
|
|
37
|
+
* and an unknown setting can name every valid one back.
|
|
38
|
+
*/
|
|
39
|
+
const PRIVACY: Record<
|
|
40
|
+
string,
|
|
41
|
+
{
|
|
42
|
+
values: readonly string[];
|
|
43
|
+
apply: (sock: WASocket, v: string) => Promise<void>;
|
|
44
|
+
}
|
|
45
|
+
> = {
|
|
46
|
+
last_seen: {
|
|
47
|
+
values: ["all", "contacts", "contact_blacklist", "none"],
|
|
48
|
+
apply: (s, v) => s.updateLastSeenPrivacy(v as "all"),
|
|
49
|
+
},
|
|
50
|
+
online: {
|
|
51
|
+
values: ["all", "match_last_seen"],
|
|
52
|
+
apply: (s, v) => s.updateOnlinePrivacy(v as "all"),
|
|
53
|
+
},
|
|
54
|
+
profile_photo: {
|
|
55
|
+
values: ["all", "contacts", "contact_blacklist", "none"],
|
|
56
|
+
apply: (s, v) => s.updateProfilePicturePrivacy(v as "all"),
|
|
57
|
+
},
|
|
58
|
+
about: {
|
|
59
|
+
values: ["all", "contacts", "contact_blacklist", "none"],
|
|
60
|
+
apply: (s, v) => s.updateStatusPrivacy(v as "all"),
|
|
61
|
+
},
|
|
62
|
+
read_receipts: {
|
|
63
|
+
values: ["all", "none"],
|
|
64
|
+
apply: (s, v) => s.updateReadReceiptsPrivacy(v as "all"),
|
|
65
|
+
},
|
|
66
|
+
groups_add: {
|
|
67
|
+
values: ["all", "contacts", "contact_blacklist"],
|
|
68
|
+
apply: (s, v) => s.updateGroupsAddPrivacy(v as "all"),
|
|
69
|
+
},
|
|
70
|
+
calls: {
|
|
71
|
+
values: ["all", "known"],
|
|
72
|
+
apply: (s, v) => s.updateCallPrivacy(v as "all"),
|
|
73
|
+
},
|
|
74
|
+
messages: {
|
|
75
|
+
values: ["all", "contacts"],
|
|
76
|
+
apply: (s, v) => s.updateMessagesPrivacy(v as "all"),
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Named durations for the default disappearing-message timer. */
|
|
81
|
+
const DISAPPEARING: Record<string, number> = {
|
|
82
|
+
off: 0,
|
|
83
|
+
"24h": 86400,
|
|
84
|
+
"7d": 604800,
|
|
85
|
+
"90d": 7776000,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
type Op = (
|
|
89
|
+
body: Record<string, unknown>,
|
|
90
|
+
ctx: WhatsAppActionContext,
|
|
91
|
+
) => Promise<ActionResult>;
|
|
92
|
+
|
|
93
|
+
/** Whole own-profile read: who this account is, as WhatsApp sees it. */
|
|
94
|
+
const getProfile: Op = async (_body, ctx) => {
|
|
95
|
+
const jid = ownJid(ctx.sock);
|
|
96
|
+
const lines = [
|
|
97
|
+
`jid: ${jid}`,
|
|
98
|
+
`number: +${jid.split("@")[0]}`,
|
|
99
|
+
`name: ${ctx.sock.user?.name ?? "(unset)"}`,
|
|
100
|
+
];
|
|
101
|
+
// Both reads are best-effort: a fresh account has neither, and the
|
|
102
|
+
// account's own privacy settings never hide them from itself.
|
|
103
|
+
const status = await ctx.sock.fetchStatus(jid).catch(() => undefined);
|
|
104
|
+
lines.push(`about: ${readStatusText(status) ?? "(unset)"}`);
|
|
105
|
+
const photo = await ctx.sock
|
|
106
|
+
.profilePictureUrl(jid, "image")
|
|
107
|
+
.catch(() => undefined);
|
|
108
|
+
lines.push(`photo: ${photo ?? "(none)"}`);
|
|
109
|
+
return { ok: true, text: lines.join("\n") };
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Another account's public card — what strangers see of them. */
|
|
113
|
+
const getUserProfile: Op = async (body, ctx) => {
|
|
114
|
+
const jid = toUserJid(body.contact ?? body.user_id);
|
|
115
|
+
if (!jid) {
|
|
116
|
+
return { ok: false, error: "get_user_profile: contact is required" };
|
|
117
|
+
}
|
|
118
|
+
const status = await ctx.sock.fetchStatus(jid).catch(() => undefined);
|
|
119
|
+
const photo = await ctx.sock
|
|
120
|
+
.profilePictureUrl(jid, "image")
|
|
121
|
+
.catch(() => undefined);
|
|
122
|
+
return {
|
|
123
|
+
ok: true,
|
|
124
|
+
text: [
|
|
125
|
+
`jid: ${jid}`,
|
|
126
|
+
`about: ${readStatusText(status) ?? "(hidden or unset)"}`,
|
|
127
|
+
`photo: ${photo ?? "(hidden or none)"}`,
|
|
128
|
+
].join("\n"),
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const setName: Op = async (body, ctx) => {
|
|
133
|
+
const name = String(body.name ?? "").trim();
|
|
134
|
+
if (!name) return { ok: false, error: "set_name: name is required" };
|
|
135
|
+
await ctx.sock.updateProfileName(name);
|
|
136
|
+
return { ok: true, text: `Display name set to "${name}".` };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const setAbout: Op = async (body, ctx) => {
|
|
140
|
+
// An empty string is a legitimate value here — it clears the about.
|
|
141
|
+
const text = String(body.text ?? body.about ?? "");
|
|
142
|
+
await ctx.sock.updateProfileStatus(text);
|
|
143
|
+
return {
|
|
144
|
+
ok: true,
|
|
145
|
+
text: text ? `About set to "${text}".` : "About cleared.",
|
|
146
|
+
};
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const setPhoto: Op = async (body, ctx) => {
|
|
150
|
+
const resolved = resolveMediaUpload(body, "set_photo");
|
|
151
|
+
if ("error" in resolved) return { ok: false, error: resolved.error };
|
|
152
|
+
await ctx.sock.updateProfilePicture(ownJid(ctx.sock), resolved.media);
|
|
153
|
+
return { ok: true, text: "Profile photo updated." };
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const removePhoto: Op = async (_body, ctx) => {
|
|
157
|
+
await ctx.sock.removeProfilePicture(ownJid(ctx.sock));
|
|
158
|
+
return { ok: true, text: "Profile photo removed." };
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const getPrivacy: Op = async (_body, ctx) => {
|
|
162
|
+
const settings = await ctx.sock.fetchPrivacySettings(true);
|
|
163
|
+
const rows = Object.entries(settings).map(([k, v]) => `${k}: ${v}`);
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
text: rows.length ? rows.join("\n") : "No privacy settings reported.",
|
|
167
|
+
};
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const setPrivacy: Op = async (body, ctx) => {
|
|
171
|
+
const setting = String(body.setting ?? "")
|
|
172
|
+
.trim()
|
|
173
|
+
.toLowerCase();
|
|
174
|
+
const knob = Object.hasOwn(PRIVACY, setting) ? PRIVACY[setting] : undefined;
|
|
175
|
+
if (!knob) {
|
|
176
|
+
return {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: `set_privacy: unknown setting "${setting}" — use one of ${Object.keys(PRIVACY).join(", ")}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const value = String(body.value ?? "")
|
|
182
|
+
.trim()
|
|
183
|
+
.toLowerCase();
|
|
184
|
+
if (!knob.values.includes(value)) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
error: `set_privacy: ${setting} accepts ${knob.values.join(", ")} (got "${value}")`,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
await knob.apply(ctx.sock, value);
|
|
191
|
+
return { ok: true, text: `Privacy ${setting} set to ${value}.` };
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const setDisappearing: Op = async (body, ctx) => {
|
|
195
|
+
const raw = String(body.duration ?? "")
|
|
196
|
+
.trim()
|
|
197
|
+
.toLowerCase();
|
|
198
|
+
const seconds = Object.hasOwn(DISAPPEARING, raw)
|
|
199
|
+
? DISAPPEARING[raw]
|
|
200
|
+
: Number(raw);
|
|
201
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
error: `set_disappearing: duration must be seconds, or one of ${Object.keys(DISAPPEARING).join(", ")}`,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
await ctx.sock.updateDefaultDisappearingMode(seconds);
|
|
208
|
+
return {
|
|
209
|
+
ok: true,
|
|
210
|
+
text: seconds
|
|
211
|
+
? `New chats now disappear after ${seconds}s.`
|
|
212
|
+
: "Disappearing messages off for new chats.",
|
|
213
|
+
};
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const getBlocklist: Op = async (_body, ctx) => {
|
|
217
|
+
const blocked = (await ctx.sock.fetchBlocklist()).filter(Boolean);
|
|
218
|
+
return {
|
|
219
|
+
ok: true,
|
|
220
|
+
text: blocked.length ? blocked.join("\n") : "Blocklist is empty.",
|
|
221
|
+
};
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
/** block / unblock share everything but the verb. */
|
|
225
|
+
function blockOp(action: "block" | "unblock"): Op {
|
|
226
|
+
return async (body, ctx) => {
|
|
227
|
+
const jid = toUserJid(body.contact ?? body.user_id);
|
|
228
|
+
if (!jid) return { ok: false, error: `${action}: contact is required` };
|
|
229
|
+
await ctx.sock.updateBlockStatus(jid, action);
|
|
230
|
+
return { ok: true, text: `${jid} ${action}ed.` };
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const setPresence: Op = async (body, ctx) => {
|
|
235
|
+
const state = String(body.presence ?? body.state ?? "")
|
|
236
|
+
.trim()
|
|
237
|
+
.toLowerCase();
|
|
238
|
+
if (state !== "available" && state !== "unavailable") {
|
|
239
|
+
return {
|
|
240
|
+
ok: false,
|
|
241
|
+
error: 'set_presence: presence must be "available" or "unavailable"',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
await ctx.sock.sendPresenceUpdate(state);
|
|
245
|
+
return { ok: true, text: `Presence set to ${state}.` };
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const OPS: Record<string, Op> = {
|
|
249
|
+
get_profile: getProfile,
|
|
250
|
+
get_user_profile: getUserProfile,
|
|
251
|
+
set_name: setName,
|
|
252
|
+
set_about: setAbout,
|
|
253
|
+
set_photo: setPhoto,
|
|
254
|
+
remove_photo: removePhoto,
|
|
255
|
+
get_privacy: getPrivacy,
|
|
256
|
+
set_privacy: setPrivacy,
|
|
257
|
+
set_disappearing: setDisappearing,
|
|
258
|
+
get_blocklist: getBlocklist,
|
|
259
|
+
block: blockOp("block"),
|
|
260
|
+
unblock: blockOp("unblock"),
|
|
261
|
+
set_presence: setPresence,
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/** Every op name, for the "unknown op" error to list back. */
|
|
265
|
+
const ACCOUNT_OPS: readonly string[] = Object.keys(OPS);
|
|
266
|
+
|
|
267
|
+
export const accountHandlers: WhatsAppActionHandlers = {
|
|
268
|
+
whatsapp_account: (body, _chatId, ctx) =>
|
|
269
|
+
tryAction("whatsapp_account", async () => {
|
|
270
|
+
const op = String(body.op ?? "")
|
|
271
|
+
.trim()
|
|
272
|
+
.toLowerCase();
|
|
273
|
+
const run = Object.hasOwn(OPS, op) ? OPS[op] : undefined;
|
|
274
|
+
if (!run) {
|
|
275
|
+
return {
|
|
276
|
+
ok: false,
|
|
277
|
+
error: `whatsapp_account: unknown op "${op}" — use one of ${ACCOUNT_OPS.join(", ")}`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
return run(body, ctx);
|
|
281
|
+
}),
|
|
282
|
+
};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* retrieval (get_message_by_id, download_media) lives in history.ts.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { toUserJid, tryAction } from "./shared.js";
|
|
10
|
+
import { readStatusText, toUserJid, tryAction } from "./shared.js";
|
|
11
11
|
import type { WhatsAppActionHandlers } from "./types.js";
|
|
12
12
|
|
|
13
13
|
/** Render one participant the way the other frontends render members. */
|
|
@@ -117,14 +117,7 @@ export const chatInfoHandlers: WhatsAppActionHandlers = {
|
|
|
117
117
|
const status: unknown = await ctx.sock
|
|
118
118
|
.fetchStatus(jid)
|
|
119
119
|
.catch(() => undefined);
|
|
120
|
-
|
|
121
|
-
// array across Baileys versions; accept either shape.
|
|
122
|
-
const entry = (Array.isArray(status) ? status[0] : status) as
|
|
123
|
-
{ status?: string | { status?: string } } | undefined;
|
|
124
|
-
const statusText =
|
|
125
|
-
typeof entry?.status === "string"
|
|
126
|
-
? entry.status
|
|
127
|
-
: entry?.status?.status;
|
|
120
|
+
const statusText = readStatusText(status);
|
|
128
121
|
if (statusText) lines.push(`about: ${statusText}`);
|
|
129
122
|
const picture = await ctx.sock
|
|
130
123
|
.profilePictureUrl(jid, "image")
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* WhatsApp-specific implementation.
|
|
10
10
|
*
|
|
11
11
|
* Split by responsibility (mirrors the telegram/discord actions/ layout):
|
|
12
|
+
* - account — the bot's own profile, privacy, blocklist, presence
|
|
12
13
|
* - shared — tryAction, media/quote resolution, send helpers
|
|
13
14
|
* - messaging — text, replies, reactions, edits, deletes, pins, presence
|
|
14
15
|
* - media — images, video, audio, documents, polls, locations, …
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
resolveWhatsAppTarget,
|
|
26
27
|
type WhatsAppChatInfo,
|
|
27
28
|
} from "../registry.js";
|
|
29
|
+
import { accountHandlers } from "./account.js";
|
|
28
30
|
import { chatInfoHandlers } from "./chat-info.js";
|
|
29
31
|
import { historyHandlers } from "./history.js";
|
|
30
32
|
import { mediaHandlers } from "./media.js";
|
|
@@ -35,6 +37,7 @@ import type { WhatsAppActionContext, WhatsAppActionHandlers } from "./types.js";
|
|
|
35
37
|
// Null-prototype so a request `action` of "toString" / "constructor" can't
|
|
36
38
|
// resolve an inherited Object.prototype method via `handlers[action]`.
|
|
37
39
|
const handlers: WhatsAppActionHandlers = Object.assign(Object.create(null), {
|
|
40
|
+
...accountHandlers,
|
|
38
41
|
...messagingHandlers,
|
|
39
42
|
...mediaHandlers,
|
|
40
43
|
...chatInfoHandlers,
|
|
@@ -42,8 +45,16 @@ const handlers: WhatsAppActionHandlers = Object.assign(Object.create(null), {
|
|
|
42
45
|
...moderationHandlers,
|
|
43
46
|
});
|
|
44
47
|
|
|
45
|
-
/**
|
|
46
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Actions that need no resolved chat: the store-only scheduling pair,
|
|
50
|
+
* and the account surface, which addresses the logged-in identity
|
|
51
|
+
* rather than any conversation.
|
|
52
|
+
*/
|
|
53
|
+
const CHATLESS_ACTIONS = new Set([
|
|
54
|
+
"cancel_scheduled",
|
|
55
|
+
"list_scheduled",
|
|
56
|
+
"whatsapp_account",
|
|
57
|
+
]);
|
|
47
58
|
|
|
48
59
|
export function createWhatsAppActionHandler(
|
|
49
60
|
getSock: () => WASocket | null,
|
|
@@ -304,3 +304,17 @@ export function toUserJid(value: unknown): string | null {
|
|
|
304
304
|
const digits = raw.replace(/[^0-9]/g, "");
|
|
305
305
|
return digits ? `${digits}@s.whatsapp.net` : null;
|
|
306
306
|
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Pull the about text out of a `fetchStatus` result. Baileys has
|
|
310
|
+
* returned both a bare object and a one-element array across versions,
|
|
311
|
+
* and the payload itself nests `status` either as a string or as
|
|
312
|
+
* `{ status }` — accept all four shapes rather than guess one.
|
|
313
|
+
*/
|
|
314
|
+
export function readStatusText(result: unknown): string | undefined {
|
|
315
|
+
const entry = (Array.isArray(result) ? result[0] : result) as
|
|
316
|
+
{ status?: string | { status?: string } } | undefined;
|
|
317
|
+
return typeof entry?.status === "string"
|
|
318
|
+
? entry.status
|
|
319
|
+
: entry?.status?.status;
|
|
320
|
+
}
|