talon-agent 4.0.0 → 4.1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "talon-agent",
3
- "version": "4.0.0",
3
+ "version": "4.1.0",
4
4
  "description": "Multi-frontend AI agent with full tool access, streaming, cron jobs, and plugin system",
5
5
  "author": "Dylan Neve",
6
6
  "license": "MIT",
@@ -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 plain text message through ANY
6
- * enabled messaging frontend by naming it explicitly. Explicit is the
7
- * point — the gateway's normal chat_id routing infers the owning
8
- * frontend from the numeric id's shape, and WhatsApp's hash-derived ids
9
- * also match the Telegram matcher, so a cross-frontend send routed that
10
- * way lands on the wrong platform once the originating turn's context
11
- * is cleared.
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 `send_message` — the one send action every messaging
17
- * frontend implements. A numeric target doubles as the handler's chatId
18
- * key, so id-addressed frontends (telegram, discord, teams, native)
19
- * need no changes; non-numeric forms (WhatsApp phone numbers, wa_* ids)
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 { ok: false, error: "send_via: text is required" };
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 = targets.get(frontend);
142
+ const handler = crossSendTarget(frontend);
58
143
  if (!handler) {
59
- const enabled = [...targets.keys()].sort().join(", ") || "none";
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 send_message`,
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 plain text message 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).
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
- "Message text. Markdown supported where the platform supports it.",
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),
@@ -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
- // fetchStatus has returned both a bare object and a one-element
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
- /** Actions that operate on the store alone and need no resolved chat. */
46
- const CHATLESS_ACTIONS = new Set(["cancel_scheduled", "list_scheduled"]);
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
+ }