whats-mcp 0.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.
@@ -0,0 +1,529 @@
1
+ /**
2
+ * whats-mcp — Group tools (10 tools).
3
+ *
4
+ * create_group, get_group_info, list_groups, update_group_subject,
5
+ * update_group_description, manage_group_participants, leave_group,
6
+ * manage_group_invite, update_group_settings, set_group_picture
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const {
12
+ phoneToJid, groupJid, isGroupJid, jidToPhone, resolveMedia, okResult, errResult, formatMessage,
13
+ } = require("../helpers");
14
+ const { fetchAdditionalHistory } = require("./history-support");
15
+
16
+ /**
17
+ * Normalize a JID that is expected to be a group.
18
+ * - If already @g.us → pass through.
19
+ * - If contains @ (some other domain) → pass through as-is.
20
+ * - Otherwise → append @g.us (assume bare group ID).
21
+ */
22
+ function _ensureGroupJid(jid) {
23
+ if (!jid) return jid;
24
+ if (jid.includes("@")) return jid;
25
+ return groupJid(jid);
26
+ }
27
+
28
+ function _fmtParticipant(p) {
29
+ return {
30
+ jid: p.id,
31
+ phone: jidToPhone(p.id),
32
+ admin: p.admin || null, // "admin" | "superadmin" | null
33
+ };
34
+ }
35
+
36
+ function _fmtGroupMeta(meta, options = {}) {
37
+ const allParticipants = (meta.participants || []).map(_fmtParticipant);
38
+ const includeParticipants = options.includeParticipants !== false;
39
+ const participantLimit = includeParticipants
40
+ ? Math.max(0, options.participantLimit ?? 200)
41
+ : 0;
42
+ const participants = includeParticipants
43
+ ? allParticipants.slice(0, participantLimit)
44
+ : undefined;
45
+
46
+ return {
47
+ jid: meta.id,
48
+ subject: meta.subject,
49
+ subject_owner: meta.subjectOwner || null,
50
+ subject_time: meta.subjectTime ? Number(meta.subjectTime) : null,
51
+ description: meta.desc || null,
52
+ description_id: meta.descId || null,
53
+ owner: meta.owner || null,
54
+ creation_time: meta.creation ? Number(meta.creation) : null,
55
+ recent_messages: options.recentMessages || [],
56
+ recent_message_count: (options.recentMessages || []).length,
57
+ history_sync: options.historySync || null,
58
+ participant_count: allParticipants.length,
59
+ participants_returned: includeParticipants ? participants.length : 0,
60
+ participants_truncated: includeParticipants ? participants.length < allParticipants.length : allParticipants.length > 0,
61
+ participants,
62
+ size: meta.size || allParticipants.length,
63
+ announce: meta.announce ?? false, // only admins can send
64
+ restrict: meta.restrict ?? false, // only admins can edit info
65
+ ephemeral: meta.ephemeralDuration || 0, // disappearing timer
66
+ invite_code: meta.inviteCode || null,
67
+ linked_parent: meta.linkedParent || null, // community parent
68
+ };
69
+ }
70
+
71
+ module.exports = [
72
+ // 1. create_group
73
+ {
74
+ definition: {
75
+ name: "create_group",
76
+ description:
77
+ "Create a new WhatsApp group." +
78
+ " You must provide at least 1 participant besides yourself.",
79
+ inputSchema: {
80
+ type: "object",
81
+ properties: {
82
+ subject: { type: "string", description: "Group name/subject." },
83
+ participants: {
84
+ type: "array",
85
+ items: { type: "string" },
86
+ description: "Array of participant JIDs or phone numbers to add.",
87
+ },
88
+ description: { type: "string", description: "Optional group description." },
89
+ },
90
+ required: ["subject", "participants"],
91
+ },
92
+ },
93
+ handler: async ({ subject, participants, description }, { sock }) => {
94
+ const jids = participants.map(phoneToJid);
95
+ const result = await sock.groupCreate(subject, jids);
96
+ if (description && result.id) {
97
+ try {
98
+ await sock.groupUpdateDescription(result.id, description);
99
+ } catch { /* ignore description failure */ }
100
+ }
101
+ return okResult({
102
+ status: "created",
103
+ jid: result.id,
104
+ subject: result.subject || subject,
105
+ participants: result.participants || jids.map((j) => ({ jid: j })),
106
+ });
107
+ },
108
+ },
109
+
110
+ // 2. get_group_info
111
+ {
112
+ definition: {
113
+ name: "get_group_info",
114
+ description:
115
+ "Get full metadata for a group: subject, description, participants, settings, etc.",
116
+ inputSchema: {
117
+ type: "object",
118
+ properties: {
119
+ jid: { type: "string", description: "Group JID (e.g. 120363xxx@g.us)." },
120
+ recent_messages_limit: {
121
+ type: "integer",
122
+ description: "Include up to this many recent cached messages before the participant list (default 10, max 50).",
123
+ },
124
+ hydrate_messages: {
125
+ type: "boolean",
126
+ description: "If true (default), request additional older history from WhatsApp when the local cache is too small.",
127
+ },
128
+ history_count: {
129
+ type: "integer",
130
+ description: "How many older messages to request during on-demand history sync (default: max(recent_messages_limit, 50), max 200).",
131
+ },
132
+ history_wait_ms: {
133
+ type: "integer",
134
+ description: "How long to wait for incoming history-sync events after requesting older messages (default 3500ms, max 15000ms).",
135
+ },
136
+ include_participants: {
137
+ type: "boolean",
138
+ description: "Whether to include participant details in the response (default true).",
139
+ },
140
+ participant_limit: {
141
+ type: "integer",
142
+ description: "Maximum number of participants to include in the response (default 200).",
143
+ },
144
+ },
145
+ required: ["jid"],
146
+ },
147
+ },
148
+ handler: async ({
149
+ jid,
150
+ recent_messages_limit,
151
+ hydrate_messages,
152
+ history_count,
153
+ history_wait_ms,
154
+ include_participants,
155
+ participant_limit,
156
+ }, { sock, store }) => {
157
+ const gJid = _ensureGroupJid(jid);
158
+ if (!isGroupJid(gJid)) {
159
+ return errResult("Provided JID is not a group. Group JIDs end with @g.us.");
160
+ }
161
+ // Try live fetch first, fallback to cache
162
+ let meta;
163
+ try {
164
+ meta = await sock.groupMetadata(gJid);
165
+ } catch {
166
+ meta = store.getGroupMeta(gJid);
167
+ if (!meta) return errResult(`Could not retrieve metadata for group ${gJid}.`);
168
+ }
169
+ // Also cache it
170
+ store.setGroupMeta(gJid, meta);
171
+
172
+ const recentLimit = Math.min(Math.max(recent_messages_limit || 10, 0), 50);
173
+ let historySync = {
174
+ enabled: hydrate_messages !== false,
175
+ requested: false,
176
+ received: false,
177
+ reason: recentLimit > 0 ? "cache_sufficient" : "disabled",
178
+ before_count: store.countMessages(gJid),
179
+ after_count: store.countMessages(gJid),
180
+ };
181
+
182
+ if (recentLimit > 0 && hydrate_messages !== false) {
183
+ const cachedMessages = store.getMessages(gJid, recentLimit);
184
+ if (cachedMessages.length < recentLimit) {
185
+ historySync = await fetchAdditionalHistory({
186
+ sock,
187
+ store,
188
+ jid: gJid,
189
+ limit: recentLimit,
190
+ historyCount: history_count,
191
+ waitMs: history_wait_ms,
192
+ enabled: hydrate_messages !== false,
193
+ });
194
+ }
195
+ }
196
+
197
+ const recentMessages = recentLimit > 0
198
+ ? store.getMessages(gJid, recentLimit).map(formatMessage).filter(Boolean)
199
+ : [];
200
+
201
+ return okResult(_fmtGroupMeta(meta, {
202
+ recentMessages,
203
+ historySync,
204
+ includeParticipants: include_participants,
205
+ participantLimit: participant_limit,
206
+ }));
207
+ },
208
+ },
209
+
210
+ // 3. list_groups
211
+ {
212
+ definition: {
213
+ name: "list_groups",
214
+ description: "List all groups you are a member of.",
215
+ inputSchema: {
216
+ type: "object",
217
+ properties: {
218
+ limit: { type: "integer", description: "Max number of groups to return (default 50)." },
219
+ },
220
+ },
221
+ },
222
+ handler: async ({ limit }, { sock, store }) => {
223
+ const seen = new Set();
224
+ const groups = [];
225
+ const lim = limit || 50;
226
+
227
+ for (const chat of store.listChats(10000)) {
228
+ if (!isGroupJid(chat.id) || seen.has(chat.id)) continue;
229
+ seen.add(chat.id);
230
+ groups.push(chat);
231
+ if (groups.length >= lim) break;
232
+ }
233
+
234
+ if (groups.length < lim) {
235
+ for (const meta of store.groupMeta.values()) {
236
+ if (!meta?.id || seen.has(meta.id)) continue;
237
+ seen.add(meta.id);
238
+ groups.push({
239
+ id: meta.id,
240
+ name: meta.subject,
241
+ conversationTimestamp: meta.subjectTime || meta.creation || 0,
242
+ });
243
+ if (groups.length >= lim) break;
244
+ }
245
+ }
246
+
247
+ // Enrich with metadata if available
248
+ const results = [];
249
+ for (const g of groups) {
250
+ let meta = store.getGroupMeta(g.id);
251
+ if (!meta) {
252
+ try {
253
+ meta = await sock.groupMetadata(g.id);
254
+ store.setGroupMeta(g.id, meta);
255
+ } catch { /* skip */ }
256
+ }
257
+ results.push({
258
+ jid: g.id,
259
+ subject: meta?.subject || g.name || g.id,
260
+ participant_count: meta?.participants?.length || meta?.size || null,
261
+ creation_time: meta?.creation ? Number(meta.creation) : null,
262
+ announce: meta?.announce ?? null,
263
+ });
264
+ }
265
+
266
+ return okResult({ count: results.length, groups: results });
267
+ },
268
+ },
269
+
270
+ // 4. update_group_subject
271
+ {
272
+ definition: {
273
+ name: "update_group_subject",
274
+ description: "Change the group name/subject.",
275
+ inputSchema: {
276
+ type: "object",
277
+ properties: {
278
+ jid: { type: "string", description: "Group JID." },
279
+ subject: { type: "string", description: "New group name (max 25 characters)." },
280
+ },
281
+ required: ["jid", "subject"],
282
+ },
283
+ },
284
+ handler: async ({ jid, subject }, { sock }) => {
285
+ const gJid = _ensureGroupJid(jid);
286
+ await sock.groupUpdateSubject(gJid, subject);
287
+ return okResult({ status: "updated", jid: gJid, subject });
288
+ },
289
+ },
290
+
291
+ // 5. update_group_description
292
+ {
293
+ definition: {
294
+ name: "update_group_description",
295
+ description: "Update or clear the group description.",
296
+ inputSchema: {
297
+ type: "object",
298
+ properties: {
299
+ jid: { type: "string", description: "Group JID." },
300
+ description: { type: "string", description: "New description. Empty string to clear." },
301
+ },
302
+ required: ["jid", "description"],
303
+ },
304
+ },
305
+ handler: async ({ jid, description }, { sock }) => {
306
+ const gJid = _ensureGroupJid(jid);
307
+ await sock.groupUpdateDescription(gJid, description || undefined);
308
+ return okResult({ status: "updated", jid: gJid });
309
+ },
310
+ },
311
+
312
+ // 6. manage_group_participants
313
+ {
314
+ definition: {
315
+ name: "manage_group_participants",
316
+ description:
317
+ "Add, remove, promote (to admin), or demote (from admin) group participants.",
318
+ inputSchema: {
319
+ type: "object",
320
+ properties: {
321
+ jid: { type: "string", description: "Group JID." },
322
+ action: {
323
+ type: "string",
324
+ enum: ["add", "remove", "promote", "demote"],
325
+ description: "Action to perform on participants.",
326
+ },
327
+ participants: {
328
+ type: "array",
329
+ items: { type: "string" },
330
+ description: "Array of participant JIDs or phone numbers.",
331
+ },
332
+ },
333
+ required: ["jid", "action", "participants"],
334
+ },
335
+ },
336
+ handler: async ({ jid, action, participants }, { sock }) => {
337
+ const gJid = _ensureGroupJid(jid);
338
+ const pJids = participants.map(phoneToJid);
339
+ const result = await sock.groupParticipantsUpdate(gJid, pJids, action);
340
+ return okResult({
341
+ status: action,
342
+ jid: gJid,
343
+ participants: result || pJids.map((p) => ({ jid: p, status: "ok" })),
344
+ });
345
+ },
346
+ },
347
+
348
+ // 7. leave_group
349
+ {
350
+ definition: {
351
+ name: "leave_group",
352
+ description: "Leave a group.",
353
+ inputSchema: {
354
+ type: "object",
355
+ properties: {
356
+ jid: { type: "string", description: "Group JID." },
357
+ },
358
+ required: ["jid"],
359
+ },
360
+ },
361
+ handler: async ({ jid }, { sock }) => {
362
+ const gJid = _ensureGroupJid(jid);
363
+ await sock.groupLeave(gJid);
364
+ return okResult({ status: "left", jid: gJid });
365
+ },
366
+ },
367
+
368
+ // 8. manage_group_invite
369
+ {
370
+ definition: {
371
+ name: "manage_group_invite",
372
+ description:
373
+ "Get, revoke, or join a group via invite link/code." +
374
+ " 'get' returns the current invite link, 'revoke' generates a new one," +
375
+ " 'join' joins the group given an invite code.",
376
+ inputSchema: {
377
+ type: "object",
378
+ properties: {
379
+ action: {
380
+ type: "string",
381
+ enum: ["get", "revoke", "join"],
382
+ description: "Action to perform.",
383
+ },
384
+ jid: {
385
+ type: "string",
386
+ description: "Group JID (required for 'get' and 'revoke').",
387
+ },
388
+ code: {
389
+ type: "string",
390
+ description: "Invite code or full link (required for 'join'). E.g. 'ABcdEfGhIjK' or 'https://chat.whatsapp.com/ABcdEfGhIjK'.",
391
+ },
392
+ },
393
+ required: ["action"],
394
+ },
395
+ },
396
+ handler: async ({ action, jid, code }, { sock }) => {
397
+ if (action === "get") {
398
+ if (!jid) return errResult("JID is required for 'get' action.");
399
+ const gJid = _ensureGroupJid(jid);
400
+ const inviteCode = await sock.groupInviteCode(gJid);
401
+ return okResult({
402
+ jid: gJid,
403
+ invite_code: inviteCode,
404
+ invite_link: `https://chat.whatsapp.com/${inviteCode}`,
405
+ });
406
+ }
407
+ if (action === "revoke") {
408
+ if (!jid) return errResult("JID is required for 'revoke' action.");
409
+ const gJid = _ensureGroupJid(jid);
410
+ const newCode = await sock.groupRevokeInvite(gJid);
411
+ return okResult({
412
+ jid: gJid,
413
+ invite_code: newCode,
414
+ invite_link: `https://chat.whatsapp.com/${newCode}`,
415
+ note: "Previous invite link has been revoked.",
416
+ });
417
+ }
418
+ if (action === "join") {
419
+ if (!code) return errResult("Invite code is required for 'join' action.");
420
+ // Extract code from full URL if given
421
+ const inviteCode = code.replace("https://chat.whatsapp.com/", "").trim();
422
+ const gJid = await sock.groupAcceptInvite(inviteCode);
423
+ return okResult({ status: "joined", jid: gJid, invite_code: inviteCode });
424
+ }
425
+ return errResult(`Unknown action: ${action}`);
426
+ },
427
+ },
428
+
429
+ // 9. update_group_settings
430
+ {
431
+ definition: {
432
+ name: "update_group_settings",
433
+ description:
434
+ "Update group settings: announcement mode (only admins send)," +
435
+ " locked mode (only admins edit info), disappearing messages, member add mode," +
436
+ " and join approval mode.",
437
+ inputSchema: {
438
+ type: "object",
439
+ properties: {
440
+ jid: { type: "string", description: "Group JID." },
441
+ announce: {
442
+ type: "boolean",
443
+ description: "true = only admins can send messages, false = all members can send.",
444
+ },
445
+ locked: {
446
+ type: "boolean",
447
+ description: "true = only admins can edit group info, false = all members can.",
448
+ },
449
+ ephemeral: {
450
+ type: "integer",
451
+ description: "Disappearing messages timer in seconds: 0=off, 86400=24h, 604800=7d, 7776000=90d.",
452
+ },
453
+ member_add_mode: {
454
+ type: "boolean",
455
+ description: "true = all members can add participants, false = only admins.",
456
+ },
457
+ join_approval_mode: {
458
+ type: "boolean",
459
+ description: "true = admin approval required for join requests.",
460
+ },
461
+ },
462
+ required: ["jid"],
463
+ },
464
+ },
465
+ handler: async ({ jid, announce, locked, ephemeral, member_add_mode, join_approval_mode }, { sock }) => {
466
+ const gJid = _ensureGroupJid(jid);
467
+ const updates = [];
468
+
469
+ if (announce !== undefined) {
470
+ await sock.groupSettingUpdate(gJid, announce ? "announcement" : "not_announcement");
471
+ updates.push(`announce=${announce}`);
472
+ }
473
+ if (locked !== undefined) {
474
+ await sock.groupSettingUpdate(gJid, locked ? "locked" : "unlocked");
475
+ updates.push(`locked=${locked}`);
476
+ }
477
+ if (ephemeral !== undefined) {
478
+ await sock.sendMessage(gJid, { disappearingMessagesInChat: ephemeral });
479
+ updates.push(`ephemeral=${ephemeral}`);
480
+ }
481
+ if (member_add_mode !== undefined) {
482
+ await sock.groupMemberAddMode(gJid, member_add_mode ? "all_member_add" : "admin_add");
483
+ updates.push(`member_add_mode=${member_add_mode}`);
484
+ }
485
+ if (join_approval_mode !== undefined) {
486
+ await sock.groupJoinApprovalMode(gJid, join_approval_mode ? "on" : "off");
487
+ updates.push(`join_approval_mode=${join_approval_mode}`);
488
+ }
489
+
490
+ if (updates.length === 0) {
491
+ return errResult("No settings provided. Specify at least one setting to update.");
492
+ }
493
+ return okResult({ status: "updated", jid: gJid, changes: updates });
494
+ },
495
+ },
496
+
497
+ // 10. set_group_picture
498
+ {
499
+ definition: {
500
+ name: "set_group_picture",
501
+ description: "Set or update the group profile picture.",
502
+ inputSchema: {
503
+ type: "object",
504
+ properties: {
505
+ jid: { type: "string", description: "Group JID." },
506
+ source: { type: "string", description: "Image source: URL, base64, or local file path." },
507
+ },
508
+ required: ["jid", "source"],
509
+ },
510
+ },
511
+ handler: async ({ jid, source }, { sock }) => {
512
+ const gJid = _ensureGroupJid(jid);
513
+ const media = resolveMedia(source);
514
+ // updateProfilePicture expects a Buffer
515
+ let imgBuf;
516
+ if (Buffer.isBuffer(media)) {
517
+ imgBuf = media;
518
+ } else if (media.url) {
519
+ // Fetch from URL
520
+ const resp = await fetch(media.url);
521
+ imgBuf = Buffer.from(await resp.arrayBuffer());
522
+ } else {
523
+ imgBuf = media;
524
+ }
525
+ await sock.updateProfilePicture(gJid, imgBuf);
526
+ return okResult({ status: "updated", jid: gJid });
527
+ },
528
+ },
529
+ ];
@@ -0,0 +1,114 @@
1
+ "use strict";
2
+
3
+ function sleep(ms) {
4
+ return new Promise((resolve) => setTimeout(resolve, ms));
5
+ }
6
+
7
+ function getMessageTimestampSeconds(message) {
8
+ if (!message?.messageTimestamp) return 0;
9
+ return typeof message.messageTimestamp === "number"
10
+ ? message.messageTimestamp
11
+ : Number(message.messageTimestamp);
12
+ }
13
+
14
+ function getMessageTimestampMs(message) {
15
+ return getMessageTimestampSeconds(message) * 1000;
16
+ }
17
+
18
+ async function fetchAdditionalHistory({
19
+ sock,
20
+ store,
21
+ jid,
22
+ beforeId,
23
+ limit = 50,
24
+ historyCount,
25
+ waitMs = 3500,
26
+ enabled = true,
27
+ }) {
28
+ const beforeCount = typeof store.countMessages === "function"
29
+ ? store.countMessages(jid)
30
+ : (store.messages.get(jid) || []).length;
31
+
32
+ const result = {
33
+ enabled: enabled !== false,
34
+ requested: false,
35
+ received: false,
36
+ reason: null,
37
+ before_count: beforeCount,
38
+ after_count: beforeCount,
39
+ anchor_id: null,
40
+ requested_count: 0,
41
+ wait_ms: Math.max(250, Math.min(waitMs || 3500, 15000)),
42
+ };
43
+
44
+ if (enabled === false) {
45
+ result.reason = "disabled";
46
+ return result;
47
+ }
48
+
49
+ if (!sock || typeof sock.fetchMessageHistory !== "function") {
50
+ result.reason = "unsupported";
51
+ return result;
52
+ }
53
+
54
+ let anchor = beforeId ? store.getMessage(beforeId) : null;
55
+ if (!anchor && typeof store.getOldestMessage === "function") {
56
+ anchor = store.getOldestMessage(jid);
57
+ }
58
+
59
+ if (!anchor?.key?.id || !anchor?.key?.remoteJid) {
60
+ result.reason = "no_anchor";
61
+ return result;
62
+ }
63
+
64
+ const anchorTimestampSeconds = getMessageTimestampSeconds(anchor);
65
+ if (!anchorTimestampSeconds) {
66
+ result.reason = "missing_anchor_timestamp";
67
+ return result;
68
+ }
69
+
70
+ const initialOldest = typeof store.getOldestMessage === "function"
71
+ ? store.getOldestMessage(jid)
72
+ : anchor;
73
+ const initialOldestId = initialOldest?.key?.id || null;
74
+ const initialOldestTs = getMessageTimestampSeconds(initialOldest) || anchorTimestampSeconds;
75
+ const requestedCount = Math.max(1, Math.min(historyCount || Math.max(limit, 50), 200));
76
+
77
+ await sock.fetchMessageHistory(requestedCount, anchor.key, getMessageTimestampMs(anchor));
78
+ result.requested = true;
79
+ result.anchor_id = anchor.key.id;
80
+ result.requested_count = requestedCount;
81
+
82
+ const deadline = Date.now() + result.wait_ms;
83
+ while (Date.now() < deadline) {
84
+ await sleep(250);
85
+
86
+ const afterCount = typeof store.countMessages === "function"
87
+ ? store.countMessages(jid)
88
+ : (store.messages.get(jid) || []).length;
89
+ const oldest = typeof store.getOldestMessage === "function"
90
+ ? store.getOldestMessage(jid)
91
+ : null;
92
+ const oldestId = oldest?.key?.id || null;
93
+ const oldestTs = getMessageTimestampSeconds(oldest);
94
+
95
+ if (
96
+ afterCount > beforeCount
97
+ || (oldestId && oldestId !== initialOldestId)
98
+ || (oldestTs && oldestTs < initialOldestTs)
99
+ ) {
100
+ result.received = true;
101
+ break;
102
+ }
103
+ }
104
+
105
+ result.after_count = typeof store.countMessages === "function"
106
+ ? store.countMessages(jid)
107
+ : (store.messages.get(jid) || []).length;
108
+ result.reason = result.received ? "history_updated" : "timeout";
109
+ return result;
110
+ }
111
+
112
+ module.exports = {
113
+ fetchAdditionalHistory,
114
+ };