untappd-mcp 2.3.2 → 2.5.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +185 -5
- package/dist/bundle.js +6579 -1752
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/db.js +100 -10
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1424 -24
- package/dist/cache/sync-beers.js +51 -7
- package/dist/cache/sync-brewery-likes.js +124 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +38 -0
- package/dist/compact.js +47 -42
- package/dist/index.js +27 -4
- package/dist/menu/match.js +178 -0
- package/dist/menu/pick.js +520 -0
- package/dist/menu/venue-menu.js +108 -0
- package/dist/quota.js +14 -0
- package/dist/ratelimit.js +236 -0
- package/dist/tools/beer.js +4 -1
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +506 -62
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/menu.js +104 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +91 -115
- package/dist/version.js +1 -1
- package/mint.yaml +17 -0
- package/package.json +5 -1
- package/server.json +16 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
package/dist/tools/cache.js
CHANGED
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { RateLimitError, confirmTokenParam, createHelpfulError, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
-
import { beerMetaFrom } from '../cache/store.js';
|
|
3
|
+
import { beerMetaFrom, DEFAULT_VISIT_RADIUS_M } from '../cache/store.js';
|
|
4
4
|
import { syncCheckins } from '../cache/sync.js';
|
|
5
5
|
import { syncUserBeers } from '../cache/sync-beers.js';
|
|
6
|
+
import { syncUserVenues, VENUES_LIST, VENUES_MAX_PAGES_CAP, VENUES_REFRESH_LIST } from '../cache/sync-venues.js';
|
|
7
|
+
import { syncWishlist, WISHLIST_MAX_PAGES_CAP } from '../cache/sync-wishlist.js';
|
|
8
|
+
import { syncBreweryLikes, BREWERY_LIKES_MAX_PAGES_CAP } from '../cache/sync-brewery-likes.js';
|
|
9
|
+
import { syncVenueDrinks, VENUE_DRINKS_DEFAULT_CALLS, VENUE_DRINKS_MAX_CALLS_CAP } from '../cache/venue-drinks.js';
|
|
10
|
+
import { DEFAULT_MULTI_USER_MODE, MAX_GROUP_USERS, MULTI_USER_MODES, multiHasHad, resolveUsers, } from '../cache/multi-user.js';
|
|
11
|
+
import { favoriteLookup } from '../cache/favorites.js';
|
|
6
12
|
import { CONFIRM_FLOW, confirmWrite } from './confirm.js';
|
|
13
|
+
import { clampToQuota } from '../quota.js';
|
|
14
|
+
import { rateLimitStatus } from '../ratelimit.js';
|
|
7
15
|
// Re-fetch cached beer metadata at most this often; a hit newer than this skips
|
|
8
16
|
// the beer/info API call.
|
|
9
17
|
const BEER_META_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
10
|
-
|
|
18
|
+
/**
|
|
19
|
+
* untappd_top_not_had's prefer_favorites boost, added to a beer's global rating
|
|
20
|
+
* (0–5) for ranking only: a wishlisted beer (explicit intent) gets more than one
|
|
21
|
+
* from a favorite brewery. Small enough that a clearly better beer still wins.
|
|
22
|
+
*/
|
|
23
|
+
export const WISHLIST_BOOST = 0.5;
|
|
24
|
+
export const FAVORITE_BREWERY_BOOST = 0.25;
|
|
25
|
+
export const UsernameArg = z
|
|
11
26
|
.string()
|
|
12
27
|
.min(1)
|
|
13
28
|
.optional()
|
|
14
29
|
.describe('Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).');
|
|
15
30
|
/** Resolve the target username (raw, un-encoded) — explicit arg else configured login. */
|
|
16
|
-
function resolveUser(username, loginName) {
|
|
31
|
+
export function resolveUser(username, loginName) {
|
|
17
32
|
const u = username ?? loginName;
|
|
18
33
|
if (!u) {
|
|
19
34
|
throw createHelpfulError('No username given and no configured account to fall back to.', {
|
|
@@ -22,6 +37,12 @@ function resolveUser(username, loginName) {
|
|
|
22
37
|
}
|
|
23
38
|
return u;
|
|
24
39
|
}
|
|
40
|
+
/** Report the quota only when Untappd told us it (and say when it cut the budget). */
|
|
41
|
+
export function quotaFields(q) {
|
|
42
|
+
if (q.quota_remaining === null)
|
|
43
|
+
return {};
|
|
44
|
+
return { quota_remaining: q.quota_remaining, ...(q.clamped ? { budget_clamped_to_quota: true } : {}) };
|
|
45
|
+
}
|
|
25
46
|
function pct(cached, total, complete) {
|
|
26
47
|
if (total && total > 0)
|
|
27
48
|
return Math.min(100, Math.round((cached / total) * 100));
|
|
@@ -33,7 +54,7 @@ function pct(cached, total, complete) {
|
|
|
33
54
|
* by the API for non-self accounts) and the user/beers distinct-beers sync
|
|
34
55
|
* (complete has-had coverage) — so a caller knows exactly which caveats apply.
|
|
35
56
|
*/
|
|
36
|
-
async function freshness(cache, username) {
|
|
57
|
+
export async function freshness(cache, username) {
|
|
37
58
|
const state = await cache.getState(username);
|
|
38
59
|
const cachedCheckins = await cache.cachedCount(username);
|
|
39
60
|
const cachedBeers = await cache.distinctBeersCount(username);
|
|
@@ -78,12 +99,114 @@ async function freshness(cache, username) {
|
|
|
78
99
|
...(caveat ? { caveat } : {}),
|
|
79
100
|
};
|
|
80
101
|
}
|
|
102
|
+
/** Several users' has-had freshness: every block, all-complete, and one caveat naming the incomplete users. */
|
|
103
|
+
export async function groupFreshness(cache, usernames) {
|
|
104
|
+
const byUser = {};
|
|
105
|
+
const incomplete = [];
|
|
106
|
+
for (const u of usernames) {
|
|
107
|
+
const f = await freshness(cache, u);
|
|
108
|
+
byUser[u] = f;
|
|
109
|
+
if (!f.coverage_complete)
|
|
110
|
+
incomplete.push(u);
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
coverage_complete: incomplete.length === 0,
|
|
114
|
+
by_user: byUser,
|
|
115
|
+
...(incomplete.length
|
|
116
|
+
? {
|
|
117
|
+
caveat: `Has-had coverage is incomplete for ${incomplete.join(', ')} (see by_user) — a beer counted as not had by ` +
|
|
118
|
+
'them may be a false negative. Run untappd_sync_user_beers for each until complete.',
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const UsernamesArg = z
|
|
124
|
+
.array(z.string().min(1))
|
|
125
|
+
.min(1)
|
|
126
|
+
.max(MAX_GROUP_USERS)
|
|
127
|
+
.optional()
|
|
128
|
+
.describe(`Check a GROUP of users (1–${MAX_GROUP_USERS}) instead of one — e.g. everyone at the table. Use instead of username; ` +
|
|
129
|
+
'each must be synced (untappd_sync_user_beers).');
|
|
130
|
+
const ModeArg = z
|
|
131
|
+
.enum(MULTI_USER_MODES)
|
|
132
|
+
.optional()
|
|
133
|
+
.describe('With usernames: "none_had" (default) keeps beers NONE of them has had (new to everyone); "any_not_had" keeps ' +
|
|
134
|
+
'beers at least one of them has not had.');
|
|
135
|
+
/**
|
|
136
|
+
* Freshness block for venue-history reads (untappd_cache_has_visited): how much
|
|
137
|
+
* of the user's user/venues list is cached, so a "not visited" can be flagged
|
|
138
|
+
* as a possible false negative.
|
|
139
|
+
*/
|
|
140
|
+
export async function venueFreshness(cache, username) {
|
|
141
|
+
const state = await cache.getListState(username, VENUES_LIST);
|
|
142
|
+
const refresh = await cache.getListState(username, VENUES_REFRESH_LIST);
|
|
143
|
+
const cached = await cache.userVenuesCount(username);
|
|
144
|
+
const backfilled = state?.complete ?? false;
|
|
145
|
+
// A cut-short incremental refresh means re-visited or new venues below its
|
|
146
|
+
// cursor may be missing, so the history is not authoritative until it ends.
|
|
147
|
+
const refreshing = backfilled && !!refresh && !refresh.complete;
|
|
148
|
+
const complete = backfilled && !refreshing;
|
|
149
|
+
const total = state?.total_count ?? null;
|
|
150
|
+
const percent = pct(cached, total, complete);
|
|
151
|
+
let caveat;
|
|
152
|
+
if (!state?.last_synced_at && cached === 0) {
|
|
153
|
+
caveat =
|
|
154
|
+
"This user's venue history has never been synced — the cache is EMPTY. Run untappd_sync_user_venues first; results here are not authoritative.";
|
|
155
|
+
}
|
|
156
|
+
else if (refreshing) {
|
|
157
|
+
caveat =
|
|
158
|
+
'A venue-history refresh is unfinished — recently visited or new venues may be missing or show a stale recent_visit, so a "not visited" may be a false negative. Run untappd_sync_user_venues until another_run_needed is false.';
|
|
159
|
+
}
|
|
160
|
+
else if (!complete) {
|
|
161
|
+
caveat = `Venue history incomplete (${percent !== null ? `~${percent}%` : 'unknown'} cached). A "not visited" may be a false negative — run untappd_sync_user_venues until another_run_needed is false.`;
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
last_synced_at: state?.last_synced_at ?? null,
|
|
165
|
+
coverage_complete: complete,
|
|
166
|
+
...(refreshing ? { refresh_in_progress: true } : {}),
|
|
167
|
+
cached_venues: cached,
|
|
168
|
+
total_venues: total,
|
|
169
|
+
percent,
|
|
170
|
+
...(caveat ? { caveat } : {}),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
81
173
|
/**
|
|
82
174
|
* Register the check-in cache tools. `cacheProvider` supplies the backing store
|
|
83
175
|
* (a `node:sqlite` file here) and is required so this module imports no
|
|
84
176
|
* platform-specific code.
|
|
85
177
|
*/
|
|
86
178
|
export function registerCacheTools(server, client, cacheProvider) {
|
|
179
|
+
server.registerTool('untappd_cache_status', {
|
|
180
|
+
title: 'Show the local cache location, schema and per-user coverage',
|
|
181
|
+
description: 'Diagnostics for the LOCAL check-in cache — NO API call. Returns the resolved cache file path, its size on ' +
|
|
182
|
+
'disk (db + WAL), journal mode, schema version, the number of cached menu-line resolutions and beer-metadata ' +
|
|
183
|
+
'rows, cached venues, and for every cached user: cached vs total distinct beers, cached check-ins, ' +
|
|
184
|
+
'coverage_complete, the resume offset of an unfinished user/beers backfill, last_synced_at, and venue-history ' +
|
|
185
|
+
'coverage (venues: cached vs total, complete, percent, next_offset; null if never synced), wishlist and ' +
|
|
186
|
+
'Untappd brewery-likes mirror coverage (wishlist / brewery_likes: cached vs total, complete; null if never ' +
|
|
187
|
+
'synced). Also reports rate_limit: API calls ' +
|
|
188
|
+
'made in the last hour (this process plus any other sharing the cache file), the ~100/hour limit, the ' +
|
|
189
|
+
'estimated calls left, the raw X-Ratelimit-* headers (the v4 API sends Remaining: 0 on every success, so it ' +
|
|
190
|
+
'is ignored until it reports a live value) and any 429 wait. Use it to confirm the cache persisted across ' +
|
|
191
|
+
'restarts, to see how far a backfill has got, or to check whether a sync can run now.',
|
|
192
|
+
annotations: toolAnnotations({ title: 'Show the local cache location, schema and per-user coverage', readOnly: true, idempotent: true, openWorld: false }),
|
|
193
|
+
inputSchema: z.object({}),
|
|
194
|
+
}, async () => {
|
|
195
|
+
const cache = cacheProvider();
|
|
196
|
+
const stats = await cache.stats();
|
|
197
|
+
// The file details only exist for a file-backed store (the node:sqlite
|
|
198
|
+
// CheckinCache); another backend reports the logical stats alone.
|
|
199
|
+
const file = cache.fileInfo?.();
|
|
200
|
+
return minifiedResult({
|
|
201
|
+
...(file ? { cache_path: file.path, file_size_bytes: file.bytes, journal_mode: file.journal_mode } : {}),
|
|
202
|
+
schema_version: stats.schema_version,
|
|
203
|
+
resolution_cache_entries: stats.resolution_cache_entries,
|
|
204
|
+
beer_meta_entries: stats.beer_meta_entries,
|
|
205
|
+
venue_entries: stats.venue_entries,
|
|
206
|
+
users: stats.users,
|
|
207
|
+
rate_limit: await rateLimitStatus(client, cache),
|
|
208
|
+
});
|
|
209
|
+
});
|
|
87
210
|
server.registerTool('untappd_sync_checkins', {
|
|
88
211
|
title: 'Sync a user\'s check-ins into the cache',
|
|
89
212
|
description: "Fetch a user's detailed check-ins (venue, date, comment) into the cache from user/checkins. Incremental and " +
|
|
@@ -92,7 +215,9 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
92
215
|
'than your own and will not page further — such a sync reports history_truncated and you should use ' +
|
|
93
216
|
'untappd_sync_user_beers for full has-had coverage. backfill_complete is only reported once ~all of ' +
|
|
94
217
|
'total_checkins is cached. Pass force_backfill: true to reset a cache wrongly marked complete and re-page ' +
|
|
95
|
-
'the whole history (cached rows are kept).
|
|
218
|
+
'the whole history (cached rows are kept). Stops BEFORE the ~100 calls/hour rate limit (or cleanly on a 429) ' +
|
|
219
|
+
'with stopped_for_rate_limit and rate_limit.resume_at — progress is saved; re-run after that time. Omit ' +
|
|
220
|
+
'username for your own account.',
|
|
96
221
|
annotations: toolAnnotations({ title: "Sync a user's check-ins into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
97
222
|
inputSchema: z.object({
|
|
98
223
|
username: UsernameArg,
|
|
@@ -110,11 +235,14 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
110
235
|
}),
|
|
111
236
|
}, async ({ username, max_pages, force_backfill }) => {
|
|
112
237
|
const user = resolveUser(username, client.loginName);
|
|
238
|
+
// Floor of 1: the shared catch-up/backfill page split (sync.ts) is only
|
|
239
|
+
// defined for a budget of at least one page.
|
|
240
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
113
241
|
const summary = await syncCheckins(client, cacheProvider(), user, {
|
|
114
|
-
maxPages:
|
|
242
|
+
maxPages: quota.budget,
|
|
115
243
|
force: force_backfill ?? false,
|
|
116
244
|
});
|
|
117
|
-
return minifiedResult(summary);
|
|
245
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
118
246
|
});
|
|
119
247
|
server.registerTool('untappd_sync_user_beers', {
|
|
120
248
|
title: "Sync a user's complete distinct-beers list into the cache",
|
|
@@ -123,7 +251,9 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
123
251
|
'"has had" coverage — thousands of beers instead of tens of thousands of check-ins — and, unlike ' +
|
|
124
252
|
'user/checkins, it pages fully for any public/friend account. Offset-paged and resumable: fetches max_pages ' +
|
|
125
253
|
'per call and persists progress; run again until another_run_needed is false. Feeds the same ' +
|
|
126
|
-
'untappd_cache_has_had / has_had_many / not_had tools.
|
|
254
|
+
'untappd_cache_has_had / has_had_many / not_had tools. Stops BEFORE the ~100 calls/hour rate limit (or ' +
|
|
255
|
+
'cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at — progress is saved; re-run after ' +
|
|
256
|
+
'that time. Omit username for your own account.',
|
|
127
257
|
annotations: toolAnnotations({ title: "Sync a user's complete distinct-beers list into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
128
258
|
inputSchema: z.object({
|
|
129
259
|
username: UsernameArg,
|
|
@@ -137,15 +267,253 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
137
267
|
}),
|
|
138
268
|
}, async ({ username, max_pages }) => {
|
|
139
269
|
const user = resolveUser(username, client.loginName);
|
|
140
|
-
const
|
|
141
|
-
|
|
270
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
271
|
+
const summary = await syncUserBeers(client, cacheProvider(), user, quota.budget);
|
|
272
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
273
|
+
});
|
|
274
|
+
server.registerTool('untappd_sync_user_venues', {
|
|
275
|
+
title: "Sync a user's complete venue history into the cache",
|
|
276
|
+
description: "Fetch a user's COMPLETE venue history (every venue they've checked in at, with first/most-recent visit dates " +
|
|
277
|
+
'and visit count) into the cache from user/venues. Unlike user/checkins — which only returns the ~50 most ' +
|
|
278
|
+
'recent check-ins for anyone but yourself — user/venues pages any public/friend account\'s whole history, so ' +
|
|
279
|
+
'this is how to answer "has this person been here?" (via untappd_cache_has_visited). Untappd serves 25 venues ' +
|
|
280
|
+
'per page, so a 2,700-venue history is ~110 calls: resumable — fetches max_pages per call, saves progress ' +
|
|
281
|
+
'after every page; run again until another_run_needed is false. Once complete, a re-run is a cheap refresh ' +
|
|
282
|
+
'from the top that stops at the first page with no changed visits (a refresh cut short resumes where it ' +
|
|
283
|
+
'stopped; another_run_needed stays true until it finishes). Stops BEFORE the ~100 calls/hour rate ' +
|
|
284
|
+
'limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at. Omit username for your own account.',
|
|
285
|
+
annotations: toolAnnotations({ title: "Sync a user's complete venue history into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
286
|
+
inputSchema: z.object({
|
|
287
|
+
username: UsernameArg,
|
|
288
|
+
max_pages: z
|
|
289
|
+
.number()
|
|
290
|
+
.int()
|
|
291
|
+
.min(1)
|
|
292
|
+
.max(VENUES_MAX_PAGES_CAP)
|
|
293
|
+
.optional()
|
|
294
|
+
.describe('Pages (25 venues each) to fetch this run (default 10, max 50). Keep modest to respect the ~100 calls/hour rate limit.'),
|
|
295
|
+
}),
|
|
296
|
+
}, async ({ username, max_pages }) => {
|
|
297
|
+
const user = resolveUser(username, client.loginName);
|
|
298
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
299
|
+
const summary = await syncUserVenues(client, cacheProvider(), user, quota.budget);
|
|
300
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
301
|
+
});
|
|
302
|
+
server.registerTool('untappd_sync_wishlist', {
|
|
303
|
+
title: "Sync a user's wishlist into the cache",
|
|
304
|
+
description: "Mirror a user's Untappd wishlist (user/wishlist) into the local cache, so pick tools can boost wishlisted " +
|
|
305
|
+
'beers without an API call. Each sync is a full pass from the top (~25 beers per call; wishlists are small), ' +
|
|
306
|
+
'adding new beers and — once the pass finishes — dropping ones no longer wishlisted (a partial pass never ' +
|
|
307
|
+
'deletes). Resumable: saves progress every page; run again until another_run_needed is false. Stops BEFORE ' +
|
|
308
|
+
'the ~100 calls/hour rate limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at. ' +
|
|
309
|
+
'Only reads Untappd — never changes the wishlist (that is untappd_wishlist_add / _remove). Omit username for ' +
|
|
310
|
+
'your own account; another user\'s wishlist needs a public account or friendship.',
|
|
311
|
+
annotations: toolAnnotations({ title: "Sync a user's wishlist into the cache", readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
312
|
+
inputSchema: z.object({
|
|
313
|
+
username: UsernameArg,
|
|
314
|
+
max_pages: z
|
|
315
|
+
.number()
|
|
316
|
+
.int()
|
|
317
|
+
.min(1)
|
|
318
|
+
.max(WISHLIST_MAX_PAGES_CAP)
|
|
319
|
+
.optional()
|
|
320
|
+
.describe(`Pages (~25 beers each) to fetch this run (default 10, max ${WISHLIST_MAX_PAGES_CAP}).`),
|
|
321
|
+
}),
|
|
322
|
+
}, async ({ username, max_pages }) => {
|
|
323
|
+
const user = resolveUser(username, client.loginName);
|
|
324
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
325
|
+
const summary = await syncWishlist(client, cacheProvider(), user, quota.budget);
|
|
326
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
327
|
+
});
|
|
328
|
+
server.registerTool('untappd_sync_brewery_likes', {
|
|
329
|
+
title: "Sync a user's liked (followed) breweries into the cache",
|
|
330
|
+
description: "Mirror the breweries a user \"likes\" on Untappd (user/brewerylikes — the app calls them \"followings\"; " +
|
|
331
|
+
"works for other users' public/friend accounts) into the local cache. Liked breweries then count as favorites " +
|
|
332
|
+
'with source "untappd" — in untappd_favorites_list, the `favorite` field on has_had / brewery_beers / ' +
|
|
333
|
+
'venue_menu / top_not_had / trip_check results, and the prefer_favorites boost. Each sync is a full pass from ' +
|
|
334
|
+
'the top (~50 breweries per call), adding new likes and — once the pass finishes — dropping unliked ones (a ' +
|
|
335
|
+
'partial pass never deletes). Resumable: saves progress every page; run again until another_run_needed is ' +
|
|
336
|
+
'false (reports cached/total and percent). Stops BEFORE the ~100 calls/hour rate limit (or cleanly on a 429) ' +
|
|
337
|
+
'with stopped_for_rate_limit and rate_limit.resume_at. Only reads Untappd. Untappd has NO followed/favorite ' +
|
|
338
|
+
'VENUE list, so venue favorites stay derived + manual. (brewery/info\'s follow_status is the signed-in ' +
|
|
339
|
+
"account's, never another user's — this list is the way to see theirs.) Omit username for your own account.",
|
|
340
|
+
annotations: toolAnnotations({
|
|
341
|
+
title: "Sync a user's liked (followed) breweries into the cache",
|
|
342
|
+
readOnly: false,
|
|
343
|
+
idempotent: false,
|
|
344
|
+
openWorld: true,
|
|
345
|
+
destructive: false,
|
|
346
|
+
}),
|
|
347
|
+
inputSchema: z.object({
|
|
348
|
+
username: UsernameArg,
|
|
349
|
+
max_pages: z
|
|
350
|
+
.number()
|
|
351
|
+
.int()
|
|
352
|
+
.min(1)
|
|
353
|
+
.max(BREWERY_LIKES_MAX_PAGES_CAP)
|
|
354
|
+
.optional()
|
|
355
|
+
.describe(`Pages (~50 breweries each) to fetch this run (default 10, max ${BREWERY_LIKES_MAX_PAGES_CAP}).`),
|
|
356
|
+
}),
|
|
357
|
+
}, async ({ username, max_pages }) => {
|
|
358
|
+
const user = resolveUser(username, client.loginName);
|
|
359
|
+
const quota = clampToQuota(client, max_pages ?? 10, 1);
|
|
360
|
+
const summary = await syncBreweryLikes(client, cacheProvider(), user, quota.budget);
|
|
361
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
362
|
+
});
|
|
363
|
+
server.registerTool('untappd_sync_venue_drinks', {
|
|
364
|
+
title: 'Fetch what a user drank on their first/latest visit to each venue (opt-in)',
|
|
365
|
+
description: 'OPT-IN, costs API calls: for a user\'s cached venue history (run untappd_sync_user_venues first), fetch the ' +
|
|
366
|
+
'FIRST and MOST-RECENT visit check-ins of each venue via checkin/view (works for any public check-in) and cache ' +
|
|
367
|
+
'the beer + date — a PARTIAL "what did they drink there" (two check-ins per venue at most, never the full ' +
|
|
368
|
+
'list). One call per check-in, most-visited venues first, at most max_calls per run (default ' +
|
|
369
|
+
`${VENUE_DRINKS_DEFAULT_CALLS}, max ${VENUE_DRINKS_MAX_CALLS_CAP}); already-cached check-ins are never re-fetched, ` +
|
|
370
|
+
'and deleted/private ones (404) are remembered and skipped. Narrow with venue_ids and which. Stops BEFORE the ' +
|
|
371
|
+
'~100 calls/hour rate limit (or cleanly on a 429) with stopped_for_rate_limit and rate_limit.resume_at; run ' +
|
|
372
|
+
'again until another_run_needed is false. Results appear in untappd_cache_has_visited under matches[].drinks. ' +
|
|
373
|
+
'Stores only beer, brewery and date — never comments. Omit username for your own account.',
|
|
374
|
+
annotations: toolAnnotations({
|
|
375
|
+
title: 'Fetch what a user drank on their first/latest visit to each venue (opt-in)',
|
|
376
|
+
readOnly: false,
|
|
377
|
+
idempotent: false,
|
|
378
|
+
openWorld: true,
|
|
379
|
+
destructive: false,
|
|
380
|
+
}),
|
|
381
|
+
inputSchema: z.object({
|
|
382
|
+
username: UsernameArg,
|
|
383
|
+
max_calls: z
|
|
384
|
+
.number()
|
|
385
|
+
.int()
|
|
386
|
+
.min(1)
|
|
387
|
+
.max(VENUE_DRINKS_MAX_CALLS_CAP)
|
|
388
|
+
.optional()
|
|
389
|
+
.describe(`checkin/view calls this run (default ${VENUE_DRINKS_DEFAULT_CALLS}, max ${VENUE_DRINKS_MAX_CALLS_CAP}). Each is one of the ~100 hourly calls.`),
|
|
390
|
+
venue_ids: z
|
|
391
|
+
.array(z.number().int().positive())
|
|
392
|
+
.min(1)
|
|
393
|
+
.max(100)
|
|
394
|
+
.optional()
|
|
395
|
+
.describe('Only these venues (from untappd_cache_has_visited). Default: every cached venue, most-visited first.'),
|
|
396
|
+
which: z
|
|
397
|
+
.enum(['both', 'first', 'recent'])
|
|
398
|
+
.optional()
|
|
399
|
+
.describe('Fetch each venue\'s first-visit check-in, most-recent one, or both (default both).'),
|
|
400
|
+
}),
|
|
401
|
+
}, async ({ username, max_calls, venue_ids, which }) => {
|
|
402
|
+
const user = resolveUser(username, client.loginName);
|
|
403
|
+
const quota = clampToQuota(client, max_calls ?? VENUE_DRINKS_DEFAULT_CALLS, 1);
|
|
404
|
+
const summary = await syncVenueDrinks(client, cacheProvider(), user, { maxCalls: quota.budget, venueIds: venue_ids, which });
|
|
405
|
+
return minifiedResult({ ...summary, ...quotaFields(quota) });
|
|
406
|
+
});
|
|
407
|
+
const VenueQueryArg = z.object({
|
|
408
|
+
label: z.string().max(200).optional().describe('Your own name for this query, echoed back (e.g. "Coppertail, Tampa")'),
|
|
409
|
+
venue_ids: z.array(z.number().int().positive()).max(100).optional().describe('Exact Untappd venue ids'),
|
|
410
|
+
name: z
|
|
411
|
+
.string()
|
|
412
|
+
.min(1)
|
|
413
|
+
.max(200)
|
|
414
|
+
.optional()
|
|
415
|
+
.describe('Venue name: matches a case-insensitive substring, or every significant word ("Green Bench Brewing" finds ' +
|
|
416
|
+
'"Green Bench Brewing Co." and its "(duplicate of …)" copies)'),
|
|
417
|
+
city: z.string().min(1).max(100).optional().describe('Narrow a name/geo match to venues whose city contains this'),
|
|
418
|
+
state: z.string().min(1).max(100).optional().describe('Narrow a name/geo match to venues whose state contains this (e.g. "FL")'),
|
|
419
|
+
lat: z.number().min(-90).max(90).optional().describe('Latitude of a point; needs lng'),
|
|
420
|
+
lng: z.number().min(-180).max(180).optional().describe('Longitude of a point; needs lat'),
|
|
421
|
+
radius_m: z
|
|
422
|
+
.number()
|
|
423
|
+
.positive()
|
|
424
|
+
.max(50_000)
|
|
425
|
+
.optional()
|
|
426
|
+
.describe(`Radius around lat/lng in metres (default ${DEFAULT_VISIT_RADIUS_M})`),
|
|
427
|
+
});
|
|
428
|
+
server.registerTool('untappd_cache_has_visited', {
|
|
429
|
+
title: 'Check if a user has been to a venue (from the cache)',
|
|
430
|
+
description: 'Answer "has this user been here?" for one or many places in ONE call, from the cached venue history only — ' +
|
|
431
|
+
'NO API call. Each query matches by exact venue_ids, by name (case-insensitive substring or all significant ' +
|
|
432
|
+
'words, optionally narrowed by city/state), and/or by lat/lng within radius_m (default 150 m); venue_ids are ' +
|
|
433
|
+
'unioned with the name/geo match, and name + lat/lng together must both hold. Name/location matching matters ' +
|
|
434
|
+
'because a brewery_id is NOT its taproom\'s venue_id, a brewery can have several venues, and Untappd keeps ' +
|
|
435
|
+
'"… (duplicate of …)" venues — so expect, and check, several matches. Returns per query: visited, the matched ' +
|
|
436
|
+
'venues (first_visit, recent_visit, total_count, is_duplicate, distance_m, and — once untappd_sync_venue_drinks ' +
|
|
437
|
+
'has fetched them — drinks: the beer from the first/recent visit check-ins — and favorite {is_favorite, sources, ' +
|
|
438
|
+
'reason}: derived from the visit history or added with untappd_favorite_add), and total_visits; plus a freshness ' +
|
|
439
|
+
'block (last_synced_at, coverage_complete, caveat). Run untappd_sync_user_venues first.',
|
|
440
|
+
annotations: toolAnnotations({ title: 'Check if a user has been to a venue (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
|
|
441
|
+
inputSchema: z.object({
|
|
442
|
+
username: UsernameArg,
|
|
443
|
+
queries: z.array(VenueQueryArg).min(1).max(50).describe('Places to check (1–50); each needs venue_ids, name, or lat+lng'),
|
|
444
|
+
max_matches: z.number().int().min(1).max(50).optional().describe('Most matched venues listed per query (default 10)'),
|
|
445
|
+
}),
|
|
446
|
+
}, async ({ username, queries, max_matches }) => {
|
|
447
|
+
queries.forEach((q, i) => {
|
|
448
|
+
const hasGeo = q.lat !== undefined || q.lng !== undefined;
|
|
449
|
+
if (hasGeo && (q.lat === undefined || q.lng === undefined)) {
|
|
450
|
+
throw createHelpfulError(`Query ${i + 1}: lat and lng must be given together.`, { hint: 'Pass both, or match by name / venue_ids instead.' });
|
|
451
|
+
}
|
|
452
|
+
if (!q.venue_ids?.length && q.name === undefined && !hasGeo) {
|
|
453
|
+
throw createHelpfulError(`Query ${i + 1} has nothing to match on.`, {
|
|
454
|
+
hint: 'Give venue_ids, a name (optionally with city/state), or lat + lng (optionally radius_m).',
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
const user = resolveUser(username, client.loginName);
|
|
459
|
+
const cache = cacheProvider();
|
|
460
|
+
const limit = max_matches ?? 10;
|
|
461
|
+
const results = [];
|
|
462
|
+
let visitedCount = 0;
|
|
463
|
+
const found = [];
|
|
464
|
+
for (const { label, ...q } of queries)
|
|
465
|
+
found.push({ label, q, matches: await cache.findVisitedVenues(user, q) });
|
|
466
|
+
const favs = await favoriteLookup(cache, [user], { venue: found.flatMap((f) => f.matches.slice(0, limit).map((m) => m.venue_id)) });
|
|
467
|
+
for (const { label, q, matches } of found) {
|
|
468
|
+
if (matches.length > 0)
|
|
469
|
+
visitedCount++;
|
|
470
|
+
const shown = matches.slice(0, limit);
|
|
471
|
+
const drinksByVenue = new Map();
|
|
472
|
+
for (const d of await cache.getVenueDrinks(user, shown.map((m) => m.venue_id))) {
|
|
473
|
+
const list = drinksByVenue.get(d.venue_id) ?? [];
|
|
474
|
+
list.push({ checkin_id: d.checkin_id, bid: d.bid, beer_name: d.beer_name, brewery_name: d.brewery_name, created_at: d.created_at });
|
|
475
|
+
drinksByVenue.set(d.venue_id, list);
|
|
476
|
+
}
|
|
477
|
+
results.push({
|
|
478
|
+
...(label !== undefined ? { label } : {}),
|
|
479
|
+
query: q,
|
|
480
|
+
visited: matches.length > 0,
|
|
481
|
+
match_count: matches.length,
|
|
482
|
+
total_visits: matches.reduce((n, m) => n + (m.total_count ?? 0), 0),
|
|
483
|
+
matches: shown.map((m) => ({
|
|
484
|
+
venue_id: m.venue_id,
|
|
485
|
+
name: m.name,
|
|
486
|
+
category: m.category,
|
|
487
|
+
city: m.city,
|
|
488
|
+
state: m.state,
|
|
489
|
+
country: m.country,
|
|
490
|
+
...(m.is_duplicate ? { is_duplicate: true } : {}),
|
|
491
|
+
...(m.distance_m !== undefined ? { distance_m: m.distance_m } : {}),
|
|
492
|
+
first_visit: m.first_visit,
|
|
493
|
+
recent_visit: m.recent_visit,
|
|
494
|
+
total_count: m.total_count,
|
|
495
|
+
first_checkin_id: m.first_checkin_id,
|
|
496
|
+
recent_checkin_id: m.recent_checkin_id,
|
|
497
|
+
...(drinksByVenue.has(m.venue_id) ? { drinks: drinksByVenue.get(m.venue_id) } : {}),
|
|
498
|
+
favorite: favs.get('venue', m.venue_id),
|
|
499
|
+
})),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
return minifiedResult({
|
|
503
|
+
username: user,
|
|
504
|
+
checked: queries.length,
|
|
505
|
+
visited_count: visitedCount,
|
|
506
|
+
results,
|
|
507
|
+
freshness: await venueFreshness(cache, user),
|
|
508
|
+
});
|
|
142
509
|
});
|
|
143
510
|
server.registerTool('untappd_cache_has_had', {
|
|
144
511
|
title: 'Check if a user has had a beer (from the cache)',
|
|
145
512
|
description: 'Answer "has this user ever checked in this beer?" from the cache only — NO API call. Consults BOTH cached ' +
|
|
146
513
|
'sources (check-ins and the distinct-beers list); a hit in either counts as had. Match by exact bid or a ' +
|
|
147
514
|
'case-insensitive substring of the beer name. Returns whether they had it, the times-had count, best rating, ' +
|
|
148
|
-
'last date, which sources matched,
|
|
515
|
+
'last date, which sources matched, any detailed check-ins, and favorite {is_favorite, sources, reason} (rated ' +
|
|
516
|
+
'≥4.25 / had ≥3 times, or added with untappd_favorite_add). Reports per-source freshness so you can ' +
|
|
149
517
|
'caveat incomplete data. Requires bid or beer_name. Run untappd_sync_user_beers first for full coverage.',
|
|
150
518
|
annotations: toolAnnotations({ title: 'Check if a user has had a beer (from the cache)', readOnly: true, idempotent: true, openWorld: false }),
|
|
151
519
|
inputSchema: z.object({
|
|
@@ -160,39 +528,72 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
160
528
|
const user = resolveUser(username, client.loginName);
|
|
161
529
|
const cache = cacheProvider();
|
|
162
530
|
const result = await cache.hasHad(user, { bid, beerName: beer_name });
|
|
163
|
-
|
|
531
|
+
// By bid: that beer. By name only: any matched check-in's beer that is a favorite.
|
|
532
|
+
const favBids = bid !== undefined ? [bid] : [...new Set(result.matches.map((m) => m.bid).filter((b) => b !== null))];
|
|
533
|
+
const favs = await favoriteLookup(cache, [user], { beer: favBids });
|
|
534
|
+
const favorite = favBids.map((b) => favs.get('beer', b)).find((f) => f.is_favorite) ?? favs.get('beer', null);
|
|
535
|
+
return minifiedResult({ username: user, query: { bid, beer_name }, ...result, favorite, freshness: await freshness(cache, user) });
|
|
164
536
|
});
|
|
165
537
|
server.registerTool('untappd_cache_has_had_many', {
|
|
166
538
|
title: 'Batch-check many beers against the cache',
|
|
167
539
|
description: 'Cross-check a list of beer ids against a user\'s cached history in ONE call — NO API call. Consults BOTH ' +
|
|
168
|
-
'sources (check-ins + distinct beers). Returns had/not-had per bid (with count and last date when had)
|
|
169
|
-
'
|
|
170
|
-
'
|
|
540
|
+
'sources (check-ins + distinct beers). Returns had/not-had per bid (with count and last date when had), plus ' +
|
|
541
|
+
'favorite {is_favorite, sources, reason} per bid and brewery_favorite when its brewery (from cached beer ' +
|
|
542
|
+
'metadata) is a favorite. Ideal ' +
|
|
543
|
+
'for checking a whole venue menu at once. Pass usernames (instead of username) to check a GROUP: each bid ' +
|
|
544
|
+
'then lists had_by / not_had_by and qualifies per mode — "none_had" (default: new to everyone) or ' +
|
|
545
|
+
'"any_not_had" (at least one of them still needs it) — with qualifying_bids and a per-user freshness block. ' +
|
|
546
|
+
'Run untappd_sync_user_beers first (for every user); the freshness block flags if coverage is incomplete.',
|
|
171
547
|
annotations: toolAnnotations({ title: 'Batch-check many beers against the cache', readOnly: true, idempotent: true, openWorld: false }),
|
|
172
548
|
inputSchema: z.object({
|
|
173
549
|
username: UsernameArg,
|
|
550
|
+
usernames: UsernamesArg,
|
|
551
|
+
mode: ModeArg,
|
|
174
552
|
bids: z.array(z.number().int().positive()).min(1).max(500).describe('Beer ids to check (1–500)'),
|
|
175
553
|
}),
|
|
176
|
-
}, async ({ username, bids }) => {
|
|
177
|
-
const user = resolveUser(username, client.loginName);
|
|
554
|
+
}, async ({ username, usernames, mode, bids }) => {
|
|
178
555
|
const cache = cacheProvider();
|
|
179
|
-
|
|
556
|
+
// One lookup for the whole batch (never per bid). Brewery ids come from cached beer metadata, when known.
|
|
557
|
+
const favoritesFor = async (users) => {
|
|
558
|
+
const breweryOf = new Map((await cache.getBeerMeta(bids)).map((b) => [b.bid, b.brewery_id]));
|
|
559
|
+
const favs = await favoriteLookup(cache, users, { beer: bids, brewery: [...breweryOf.values()] });
|
|
560
|
+
return (bid) => {
|
|
561
|
+
const brewery = favs.get('brewery', breweryOf.get(bid));
|
|
562
|
+
return { favorite: favs.get('beer', bid), ...(brewery.is_favorite ? { brewery_favorite: brewery } : {}) };
|
|
563
|
+
};
|
|
564
|
+
};
|
|
565
|
+
if (usernames && usernames.length > 0) {
|
|
566
|
+
const users = resolveUsers(username, usernames, client.loginName);
|
|
567
|
+
const m = mode ?? DEFAULT_MULTI_USER_MODE;
|
|
568
|
+
const group = await multiHasHad(cache, users, bids, m);
|
|
569
|
+
const favOf = await favoritesFor(users);
|
|
570
|
+
const results = [...group.values()].map((r) => ({ ...r, ...favOf(r.bid) }));
|
|
571
|
+
const qualifying = results.filter((r) => r.qualifies).map((r) => r.bid);
|
|
572
|
+
return minifiedResult({
|
|
573
|
+
usernames: users,
|
|
574
|
+
mode: m,
|
|
575
|
+
checked: results.length,
|
|
576
|
+
qualifying: qualifying.length,
|
|
577
|
+
qualifying_bids: qualifying,
|
|
578
|
+
results,
|
|
579
|
+
freshness: await groupFreshness(cache, users),
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
const user = resolveUser(username, client.loginName);
|
|
583
|
+
const verdicts = await cache.hasHadMany(user, bids);
|
|
584
|
+
const favOf = await favoritesFor([user]);
|
|
180
585
|
const results = [];
|
|
181
586
|
let hadCount = 0;
|
|
182
|
-
for (const bid of
|
|
183
|
-
if (seen.has(bid))
|
|
184
|
-
continue;
|
|
185
|
-
seen.add(bid);
|
|
186
|
-
const r = await cache.hasHad(user, { bid });
|
|
587
|
+
for (const [bid, r] of verdicts) {
|
|
187
588
|
if (r.had)
|
|
188
589
|
hadCount++;
|
|
189
|
-
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating, sources: r.sources });
|
|
590
|
+
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating, sources: r.sources, ...favOf(bid) });
|
|
190
591
|
}
|
|
191
592
|
return minifiedResult({
|
|
192
593
|
username: user,
|
|
193
|
-
checked:
|
|
594
|
+
checked: verdicts.size,
|
|
194
595
|
had: hadCount,
|
|
195
|
-
not_had:
|
|
596
|
+
not_had: verdicts.size - hadCount,
|
|
196
597
|
results,
|
|
197
598
|
freshness: await freshness(cache, user),
|
|
198
599
|
});
|
|
@@ -212,21 +613,14 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
212
613
|
}, async ({ username, bids }) => {
|
|
213
614
|
const user = resolveUser(username, client.loginName);
|
|
214
615
|
const cache = cacheProvider();
|
|
215
|
-
const
|
|
616
|
+
const verdicts = await cache.hasHadMany(user, bids);
|
|
216
617
|
const notHad = [];
|
|
217
618
|
const had = [];
|
|
218
|
-
for (const bid of
|
|
219
|
-
|
|
220
|
-
continue;
|
|
221
|
-
seen.add(bid);
|
|
222
|
-
if ((await cache.hasHad(user, { bid })).had)
|
|
223
|
-
had.push(bid);
|
|
224
|
-
else
|
|
225
|
-
notHad.push(bid);
|
|
226
|
-
}
|
|
619
|
+
for (const [bid, r] of verdicts)
|
|
620
|
+
(r.had ? had : notHad).push(bid);
|
|
227
621
|
return minifiedResult({
|
|
228
622
|
username: user,
|
|
229
|
-
checked:
|
|
623
|
+
checked: verdicts.size,
|
|
230
624
|
not_had_count: notHad.length,
|
|
231
625
|
had_count: had.length,
|
|
232
626
|
not_had: notHad,
|
|
@@ -272,10 +666,17 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
272
666
|
'run (~100 calls/hour limit) — if more are needed it returns partial: true / another_run_needed: true, so ' +
|
|
273
667
|
're-running fills the rest. A beer whose refresh is deferred or fails is still ranked on its older cached ' +
|
|
274
668
|
'metadata and marked stale: true. Reports the same freshness/caveat block as untappd_cache_not_had. Omit username ' +
|
|
275
|
-
'for your own account.'
|
|
669
|
+
'for your own account. For a GROUP pass usernames: mode "none_had" (default) ranks only beers new to everyone, ' +
|
|
670
|
+
'"any_not_had" ranks beers at least one of them still needs (each ranked beer lists not_had_by / had_by). ' +
|
|
671
|
+
`prefer_favorites (default true) lifts wishlisted beers (+${WISHLIST_BOOST}) and beers from a favorite brewery ` +
|
|
672
|
+
`(+${FAVORITE_BREWERY_BOOST}) — liked on Untappd, derived or manual, any user's in a group; the wishlist needs ` +
|
|
673
|
+
'untappd_sync_wishlist and Untappd likes untappd_sync_brewery_likes — ' +
|
|
674
|
+
'and says so per beer (score, boosted_by, wishlisted, brewery_favorite/favorite).',
|
|
276
675
|
annotations: toolAnnotations({ title: 'Top-rated beers a user has NOT had, from a candidate list', readOnly: false, idempotent: false, openWorld: true, destructive: false }),
|
|
277
676
|
inputSchema: z.object({
|
|
278
677
|
username: UsernameArg,
|
|
678
|
+
usernames: UsernamesArg,
|
|
679
|
+
mode: ModeArg,
|
|
279
680
|
bids: z.array(z.number().int().positive()).min(1).max(100).describe('Candidate beer ids (1–100)'),
|
|
280
681
|
top_n: z.number().int().min(1).max(10).optional().describe('How many top beers to return (default 2, max 10)'),
|
|
281
682
|
style: z
|
|
@@ -290,21 +691,27 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
290
691
|
.max(100)
|
|
291
692
|
.optional()
|
|
292
693
|
.describe('Max beer/info API calls this run for uncached/stale metadata (default 25). Keep modest to respect the ~100 calls/hour rate limit.'),
|
|
694
|
+
prefer_favorites: z
|
|
695
|
+
.boolean()
|
|
696
|
+
.optional()
|
|
697
|
+
.describe(`Boost beers on the wishlist (+${WISHLIST_BOOST}) or from a favorite brewery (+${FAVORITE_BREWERY_BOOST}) when ranking (default true). ` +
|
|
698
|
+
'false ranks by global rating alone.'),
|
|
293
699
|
}),
|
|
294
|
-
}, async ({ username, bids, top_n, style, api_budget }) => {
|
|
295
|
-
const
|
|
700
|
+
}, async ({ username, usernames, mode, bids, top_n, style, api_budget, prefer_favorites }) => {
|
|
701
|
+
const group = usernames !== undefined && usernames.length > 0;
|
|
702
|
+
const users = resolveUsers(username, usernames, client.loginName);
|
|
703
|
+
const groupMode = mode ?? DEFAULT_MULTI_USER_MODE;
|
|
296
704
|
const cache = cacheProvider();
|
|
297
705
|
const topN = top_n ?? 2;
|
|
298
|
-
const
|
|
706
|
+
const quota = clampToQuota(client, api_budget ?? 25);
|
|
707
|
+
const budget = quota.budget;
|
|
299
708
|
const nowMs = Date.now();
|
|
300
709
|
const nowIso = new Date().toISOString();
|
|
301
710
|
// Dedupe candidates, then filter to not-had using the cache only (no API).
|
|
711
|
+
// One user is the group case with one member (both modes mean "not had").
|
|
302
712
|
const candidates = [...new Set(bids)];
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
if (!(await cache.hasHad(user, { bid })).had)
|
|
306
|
-
notHad.push(bid);
|
|
307
|
-
}
|
|
713
|
+
const verdicts = await multiHasHad(cache, users, candidates, groupMode);
|
|
714
|
+
const notHad = candidates.filter((bid) => verdicts.get(bid).qualifies);
|
|
308
715
|
// Read cached metadata; a hit newer than the TTL is used as-is, otherwise
|
|
309
716
|
// the bid needs a beer/info fetch.
|
|
310
717
|
const metaByBid = new Map();
|
|
@@ -369,46 +776,83 @@ export function registerCacheTools(server, client, cacheProvider) {
|
|
|
369
776
|
const s = style.toLowerCase();
|
|
370
777
|
matched = available.filter((m) => (m.style?.toLowerCase().includes(s) ?? false) || (m.parent_style?.toLowerCase().includes(s) ?? false));
|
|
371
778
|
}
|
|
779
|
+
// Favorites (any group member's) and wishlists — cache only.
|
|
780
|
+
const preferFavorites = prefer_favorites ?? true;
|
|
781
|
+
const favs = await favoriteLookup(cache, users, { beer: matched.map((m) => m.bid), brewery: matched.map((m) => m.brewery_id) });
|
|
782
|
+
const wishlisted = new Set();
|
|
783
|
+
for (const u of users)
|
|
784
|
+
for (const w of await cache.getWishlist(u, matched.map((m) => m.bid)))
|
|
785
|
+
wishlisted.add(w.bid);
|
|
786
|
+
const boostsOf = (m) => {
|
|
787
|
+
const out = [];
|
|
788
|
+
if (wishlisted.has(m.bid))
|
|
789
|
+
out.push('wishlist');
|
|
790
|
+
if (favs.isFavorite('brewery', m.brewery_id))
|
|
791
|
+
out.push('favorite_brewery');
|
|
792
|
+
return out;
|
|
793
|
+
};
|
|
794
|
+
const scoreOf = (m) => ratingOf(m) +
|
|
795
|
+
(preferFavorites
|
|
796
|
+
? boostsOf(m).reduce((n, b) => n + (b === 'wishlist' ? WISHLIST_BOOST : FAVORITE_BREWERY_BOOST), 0)
|
|
797
|
+
: 0);
|
|
372
798
|
const ranked = matched
|
|
373
799
|
.slice()
|
|
374
|
-
.sort((a, b) => ratingOf(b) - ratingOf(a))
|
|
800
|
+
.sort((a, b) => scoreOf(b) - scoreOf(a) || ratingOf(b) - ratingOf(a))
|
|
375
801
|
.slice(0, topN)
|
|
376
|
-
.map((m) =>
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
brewery
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
802
|
+
.map((m) => {
|
|
803
|
+
const boosts = preferFavorites ? boostsOf(m) : [];
|
|
804
|
+
const beerFav = favs.get('beer', m.bid);
|
|
805
|
+
const breweryFav = favs.get('brewery', m.brewery_id);
|
|
806
|
+
return {
|
|
807
|
+
bid: m.bid,
|
|
808
|
+
name: m.name,
|
|
809
|
+
brewery: m.brewery,
|
|
810
|
+
style: m.style,
|
|
811
|
+
abv: m.abv,
|
|
812
|
+
rating: m.weighted_rating_score ?? m.rating_score,
|
|
813
|
+
rating_count: m.rating_count,
|
|
814
|
+
...(boosts.length ? { score: Number(scoreOf(m).toFixed(3)), boosted_by: boosts } : {}),
|
|
815
|
+
...(wishlisted.has(m.bid) ? { wishlisted: true } : {}),
|
|
816
|
+
...(beerFav.is_favorite ? { favorite: beerFav } : {}),
|
|
817
|
+
...(breweryFav.is_favorite ? { brewery_favorite: breweryFav } : {}),
|
|
818
|
+
...(staleBids.has(m.bid) ? { stale: true } : {}),
|
|
819
|
+
...(group
|
|
820
|
+
? {
|
|
821
|
+
not_had_by: verdicts.get(m.bid).not_had_by,
|
|
822
|
+
had_by: verdicts.get(m.bid).had_by.map((x) => x.username),
|
|
823
|
+
}
|
|
824
|
+
: {}),
|
|
825
|
+
};
|
|
826
|
+
});
|
|
386
827
|
// Errors leave beers unranked (or ranked on stale data) just like a
|
|
387
828
|
// deferral does, so a re-run is worth it in both cases.
|
|
388
829
|
const partial = deferred > 0 || errors > 0;
|
|
389
830
|
return minifiedResult({
|
|
390
|
-
username:
|
|
831
|
+
...(group ? { usernames: users } : { username: users[0] }),
|
|
391
832
|
ranked,
|
|
392
833
|
summary: {
|
|
834
|
+
...(group ? { mode: groupMode } : {}),
|
|
393
835
|
candidates: candidates.length,
|
|
394
836
|
not_had: notHad.length,
|
|
395
837
|
style_matched: style !== undefined ? matched.length : null,
|
|
838
|
+
prefer_favorites: preferFavorites,
|
|
396
839
|
api_calls_used: apiCalls,
|
|
397
840
|
errors,
|
|
398
841
|
stale: staleMeta.length,
|
|
399
842
|
partial,
|
|
400
843
|
another_run_needed: partial,
|
|
401
844
|
...(rateLimited ? { rate_limited: true } : {}),
|
|
845
|
+
...quotaFields(quota),
|
|
402
846
|
},
|
|
403
|
-
freshness: await freshness(cache,
|
|
847
|
+
freshness: group ? await groupFreshness(cache, users) : await freshness(cache, users[0]),
|
|
404
848
|
});
|
|
405
849
|
});
|
|
406
850
|
server.registerTool('untappd_cache_forget', {
|
|
407
851
|
title: "Forget a user's cached check-ins",
|
|
408
852
|
description: "Delete everything the LOCAL cache holds for one user — their cached check-ins (beer, rating, comment, venue, " +
|
|
409
|
-
'date), distinct-beers list and sync state — e.g. after syncing a friend you no longer want a history of. Only ' +
|
|
410
|
-
'the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it
|
|
411
|
-
'
|
|
853
|
+
'date), distinct-beers list, venue history, wishlist copy, local favorites, venue drinks and sync state — e.g. after syncing a friend you no longer want a history of. Only ' +
|
|
854
|
+
'the local cache is touched; nothing on Untappd changes, and a later sync can re-fetch it (manual favorites ' +
|
|
855
|
+
'cannot be re-fetched). Shared beer, venue and brewery facts are kept. The preview shows the username and exactly how many rows will be removed. Omit username ' +
|
|
412
856
|
`for your own account. ${CONFIRM_FLOW}`,
|
|
413
857
|
annotations: toolAnnotations({ title: "Forget a user's cached check-ins", readOnly: false, idempotent: true, openWorld: false, destructive: true }),
|
|
414
858
|
inputSchema: z.object({
|