untappd-mcp 2.4.0 → 2.5.1
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 +74 -2
- package/dist/bundle.js +5440 -1664
- package/dist/cache/brewery-beers.js +116 -0
- 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 +1203 -12
- package/dist/cache/sync-beers.js +24 -2
- package/dist/cache/sync-brewery-likes.js +125 -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 +29 -15
- package/dist/compact.js +49 -42
- package/dist/index.js +20 -3
- package/dist/menu/pick.js +7 -1
- package/dist/ratelimit.js +236 -0
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +457 -35
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +92 -38
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { FAVORITE_KINDS } from '../cache/store.js';
|
|
4
|
+
import { DEFAULT_FAVORITE_THRESHOLDS, FAVORITE_SOURCES, listFavorites } from '../cache/favorites.js';
|
|
5
|
+
import { UsernameArg, freshness, resolveUser, venueFreshness } from './cache.js';
|
|
6
|
+
const D = DEFAULT_FAVORITE_THRESHOLDS;
|
|
7
|
+
/** Every derived-favorite threshold as an optional parameter (defaults = the spec). */
|
|
8
|
+
export const FavoriteThresholdsArg = z
|
|
9
|
+
.object({
|
|
10
|
+
venue_min_checkins: z.number().int().min(1).optional().describe(`Venue: at least this many check-ins (default ${D.venue_min_checkins})`),
|
|
11
|
+
venue_min_months: z.number().int().min(1).optional().describe(`…or visits spanning at least this many distinct months (default ${D.venue_min_months})`),
|
|
12
|
+
brewery_min_beers: z.number().int().min(1).optional().describe(`Brewery: at least this many beers had… (default ${D.brewery_min_beers})`),
|
|
13
|
+
brewery_min_avg_rating: z.number().min(0).max(5).optional().describe(`…with at least this average rating (default ${D.brewery_min_avg_rating})`),
|
|
14
|
+
brewery_min_beers_any_rating: z.number().int().min(1).optional().describe(`…or at least this many beers regardless of rating (default ${D.brewery_min_beers_any_rating})`),
|
|
15
|
+
beer_min_rating: z.number().min(0).max(5).optional().describe(`Beer: rated at least this (default ${D.beer_min_rating})`),
|
|
16
|
+
beer_min_times_had: z.number().int().min(1).optional().describe(`…or had at least this many times (default ${D.beer_min_times_had})`),
|
|
17
|
+
})
|
|
18
|
+
.optional()
|
|
19
|
+
.describe('Override the derived-favorite thresholds (each optional; omitted ones keep their defaults).');
|
|
20
|
+
const KindArg = z.enum(FAVORITE_KINDS).describe('What the id is: a beer (bid), brewery (brewery_id) or venue (venue_id)');
|
|
21
|
+
const IdArg = z.number().int().positive().describe('The Untappd bid / brewery_id / venue_id');
|
|
22
|
+
const LOCAL_ONLY = 'Local to the cache — NEVER writes to Untappd (Untappd has no favorites API for this).';
|
|
23
|
+
/** untappd_favorite_add / _remove / untappd_favorites_list — the local favorites table plus derived favorites. */
|
|
24
|
+
export function registerFavoriteTools(server, client, cacheProvider) {
|
|
25
|
+
server.registerTool('untappd_favorite_add', {
|
|
26
|
+
title: 'Mark a beer, brewery or venue as a favorite (local)',
|
|
27
|
+
description: 'Add a MANUAL favorite — a beer, brewery or venue — with an optional note, to the local cache. ' +
|
|
28
|
+
`${LOCAL_ONLY} Re-adding an existing favorite updates its note (omit note to keep it) and keeps its ` +
|
|
29
|
+
'created_at. Manual favorites show in untappd_favorites_list (source "manual") and as the `favorite` field on ' +
|
|
30
|
+
'has_had, has_had_many, has_visited, brewery_beers, venue_menu, top_not_had and trip_check results; a favorite brewery also boosts its ' +
|
|
31
|
+
'beers in untappd_top_not_had. Omit username for your own account.',
|
|
32
|
+
annotations: toolAnnotations({ title: 'Mark a beer, brewery or venue as a favorite (local)', readOnly: false, idempotent: true, openWorld: false, destructive: false }),
|
|
33
|
+
inputSchema: z.object({
|
|
34
|
+
username: UsernameArg,
|
|
35
|
+
kind: KindArg,
|
|
36
|
+
id: IdArg,
|
|
37
|
+
note: z.string().max(500).optional().describe('Why it is a favorite (shown as the reason)'),
|
|
38
|
+
}),
|
|
39
|
+
}, async ({ username, kind, id, note }) => {
|
|
40
|
+
const user = resolveUser(username, client.loginName);
|
|
41
|
+
const cache = cacheProvider();
|
|
42
|
+
const r = await cache.addFavorite(user, kind, id, note, new Date().toISOString());
|
|
43
|
+
const [row] = await cache.getManualFavorites(user, { kind, ids: [id] });
|
|
44
|
+
return minifiedResult({ ...r, username: user, kind, id, name: row?.name ?? null, local_only: true });
|
|
45
|
+
});
|
|
46
|
+
server.registerTool('untappd_favorite_remove', {
|
|
47
|
+
title: 'Remove a manual favorite (local)',
|
|
48
|
+
description: `Remove a MANUAL favorite (beer, brewery or venue) from the local cache. ${LOCAL_ONLY} Returns removed:false ` +
|
|
49
|
+
'when there was none. A DERIVED favorite (from the user\'s history) cannot be removed — it is computed from ' +
|
|
50
|
+
'the thresholds; change them in untappd_favorites_list instead. Omit username for your own account.',
|
|
51
|
+
annotations: toolAnnotations({ title: 'Remove a manual favorite (local)', readOnly: false, idempotent: true, openWorld: false, destructive: false }),
|
|
52
|
+
inputSchema: z.object({ username: UsernameArg, kind: KindArg, id: IdArg }),
|
|
53
|
+
}, async ({ username, kind, id }) => {
|
|
54
|
+
const user = resolveUser(username, client.loginName);
|
|
55
|
+
const removed = await cacheProvider().removeFavorite(user, kind, id);
|
|
56
|
+
return minifiedResult({ removed, username: user, kind, id, local_only: true });
|
|
57
|
+
});
|
|
58
|
+
server.registerTool('untappd_favorites_list', {
|
|
59
|
+
title: "List a user's favorite beers, breweries and venues",
|
|
60
|
+
description: "List a user's favorites from the cache only — NO API call. Each item appears ONCE with every source that " +
|
|
61
|
+
'names it (sources: "derived" = qualifies from their cached history, "manual" = added with ' +
|
|
62
|
+
'untappd_favorite_add, "untappd" = a brewery the user likes/follows on Untappd, mirrored by ' +
|
|
63
|
+
'untappd_sync_brewery_likes — breweries only: Untappd has no followed/favorite venue list) and a reason ' +
|
|
64
|
+
'("liked on Untappd (since 2021-03)", "12 check-ins since 2015", "avg 4.1 across 9 beers", "manual: <note>"). ' +
|
|
65
|
+
'Derived rules, each ' +
|
|
66
|
+
`overridable via thresholds: venue = ≥${D.venue_min_checkins} check-ins OR visits spanning ≥${D.venue_min_months} distinct ` +
|
|
67
|
+
`months (ranked by check-ins); brewery = ≥${D.brewery_min_beers} beers had AND avg rating ≥${D.brewery_min_avg_rating}, OR ` +
|
|
68
|
+
`≥${D.brewery_min_beers_any_rating} beers (ranked by count blended with rating); beer = rated ≥${D.beer_min_rating} OR had ` +
|
|
69
|
+
`≥${D.beer_min_times_had} times. Derived favorites are only as complete as the syncs behind them ` +
|
|
70
|
+
'(untappd_sync_user_venues, untappd_sync_user_beers); "untappd" ones as the last untappd_sync_brewery_likes ' +
|
|
71
|
+
'(freshness.brewery_likes). Filter by kind and source. Omit username for your own account.',
|
|
72
|
+
annotations: toolAnnotations({ title: "List a user's favorite beers, breweries and venues", readOnly: true, idempotent: true, openWorld: false }),
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
username: UsernameArg,
|
|
75
|
+
kind: z.enum(FAVORITE_KINDS).optional().describe('Only this kind (default all three)'),
|
|
76
|
+
source: z.enum(FAVORITE_SOURCES).optional().describe('Only favorites carrying this source'),
|
|
77
|
+
thresholds: FavoriteThresholdsArg,
|
|
78
|
+
limit: z.number().int().min(1).max(500).optional().describe('Most items per kind (default 50)'),
|
|
79
|
+
}),
|
|
80
|
+
}, async ({ username, kind, source, thresholds, limit }) => {
|
|
81
|
+
const user = resolveUser(username, client.loginName);
|
|
82
|
+
const cache = cacheProvider();
|
|
83
|
+
const all = await listFavorites(cache, user, { kind, source, thresholds: thresholds });
|
|
84
|
+
const likes = await cache.breweryLikesCoverage(user);
|
|
85
|
+
const max = limit ?? 50;
|
|
86
|
+
const counts = {};
|
|
87
|
+
const favorites = all.filter((f) => {
|
|
88
|
+
counts[f.kind] = (counts[f.kind] ?? 0) + 1;
|
|
89
|
+
return counts[f.kind] <= max;
|
|
90
|
+
});
|
|
91
|
+
return minifiedResult({
|
|
92
|
+
username: user,
|
|
93
|
+
counts,
|
|
94
|
+
favorites,
|
|
95
|
+
thresholds: { ...DEFAULT_FAVORITE_THRESHOLDS, ...Object.fromEntries(Object.entries(thresholds ?? {}).filter(([, v]) => v !== undefined)) },
|
|
96
|
+
freshness: {
|
|
97
|
+
beers: await freshness(cache, user),
|
|
98
|
+
venues: await venueFreshness(cache, user),
|
|
99
|
+
brewery_likes: likes,
|
|
100
|
+
...((source === 'untappd' || source === undefined) && (kind === undefined || kind === 'brewery')
|
|
101
|
+
? {
|
|
102
|
+
untappd_source: likes?.complete
|
|
103
|
+
? 'Untappd brewery likes are mirrored; "untappd" covers breweries only (Untappd has no favorite venue list).'
|
|
104
|
+
: 'Untappd brewery likes are not (fully) synced — run untappd_sync_brewery_likes; until then "untappd" favorites may be missing.',
|
|
105
|
+
}
|
|
106
|
+
: {}),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { RateLimitError, createHelpfulError, messageOf, minifiedResult, toolAnnotations } from '@chrischall/mcp-utils';
|
|
3
|
+
import { DEFAULT_VISIT_RADIUS_M, haversineM } from '../cache/store.js';
|
|
4
|
+
import { BREWERY_BEERS_TTL_MS, getBreweryBeers } from '../cache/brewery-beers.js';
|
|
5
|
+
import { favoriteLookup, listFavorites } from '../cache/favorites.js';
|
|
6
|
+
import { DEFAULT_MULTI_USER_MODE, MAX_GROUP_USERS, MULTI_USER_MODES, multiHasHad, resolveUsers, } from '../cache/multi-user.js';
|
|
7
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
8
|
+
import { FAVORITE_BREWERY_BOOST, UsernameArg, WISHLIST_BOOST, freshness, groupFreshness, venueFreshness, } from './cache.js';
|
|
9
|
+
/** Default radius of the "near a favorite" hint around a stop. */
|
|
10
|
+
export const NEAR_FAVORITE_RADIUS_M = 2000;
|
|
11
|
+
/** Default number of not-had beers listed per brewery stop. */
|
|
12
|
+
export const TRIP_TOP_N = 4;
|
|
13
|
+
/** Default brewery/beer_list calls one trip_check may spend on missing or stale lists. */
|
|
14
|
+
export const TRIP_DEFAULT_API_BUDGET = 5;
|
|
15
|
+
/** One brewery/beer_list call asks for this many beers (top of the popularity order). */
|
|
16
|
+
const TRIP_LIST_LIMIT = 50;
|
|
17
|
+
const MAX_NEAR_FAVORITES = 5;
|
|
18
|
+
const PlaceArg = z.object({
|
|
19
|
+
label: z.string().max(200).optional().describe('Your own label for this stop, echoed back'),
|
|
20
|
+
venue_id: z.number().int().positive().optional().describe('Untappd venue id of the stop'),
|
|
21
|
+
name: z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.max(200)
|
|
25
|
+
.optional()
|
|
26
|
+
.describe('Venue name (case-insensitive substring or every significant word); defaults to the brewery name for a brewery_id stop'),
|
|
27
|
+
city: z.string().min(1).max(100).optional().describe('Narrow a name match to venues whose city contains this'),
|
|
28
|
+
state: z.string().min(1).max(100).optional().describe('Narrow a name match to venues whose state contains this (e.g. "FL")'),
|
|
29
|
+
lat: z.number().min(-90).max(90).optional().describe('Latitude of the stop; needs lng'),
|
|
30
|
+
lng: z.number().min(-180).max(180).optional().describe('Longitude of the stop; needs lat'),
|
|
31
|
+
radius_m: z.number().positive().max(50_000).optional().describe(`Visit-match radius around lat/lng in metres (default ${DEFAULT_VISIT_RADIUS_M})`),
|
|
32
|
+
brewery_id: z
|
|
33
|
+
.number()
|
|
34
|
+
.int()
|
|
35
|
+
.positive()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Untappd brewery id: adds beers-had and the top not-had beers from the brewery's (cached) beer list"),
|
|
38
|
+
});
|
|
39
|
+
const ratingOf = (r) => r.rating_score ?? 0;
|
|
40
|
+
const minIso = (xs) => xs.filter((x) => !!x).sort()[0] ?? null;
|
|
41
|
+
const maxIso = (xs) => xs.filter((x) => !!x).sort().at(-1) ?? null;
|
|
42
|
+
/** untappd_trip_check — a pre-trip sweep over several stops, cache first. */
|
|
43
|
+
export function registerTripTools(server, client, cacheProvider) {
|
|
44
|
+
server.registerTool('untappd_trip_check', {
|
|
45
|
+
title: 'Check a list of trip stops against a user’s history (cache)',
|
|
46
|
+
description: 'Plan a brewery/bar crawl in ONE call: for each place (venue_id, name + city/state, lat/lng, and/or brewery_id) ' +
|
|
47
|
+
'return whether the user has been there (dates, total visits, matched venues incl. "(duplicate of …)" copies — ' +
|
|
48
|
+
'from the cached venue history, run untappd_sync_user_venues first), and for a brewery_id: how many of its ' +
|
|
49
|
+
`beers they have had and the top ${TRIP_TOP_N} (top_n) NOT-had beers by Untappd global rating, optionally filtered by ` +
|
|
50
|
+
'style (out-of-production beers skipped unless include_out_of_production). Has-had and visits come from the cache ' +
|
|
51
|
+
'ONLY; the only API calls are brewery/beer_list fetches for a brewery whose list is not cached or is past its ' +
|
|
52
|
+
`${BREWERY_BEERS_TTL_MS / 86_400_000}-day TTL, at most api_budget (default ${TRIP_DEFAULT_API_BUDGET}; 0 = strictly cache-only, ` +
|
|
53
|
+
'a stale list is then used and flagged) under the ~100 calls/hour guard — a missing list sets another_run_needed. ' +
|
|
54
|
+
'Beer lists are global facts: Untappd\'s have_had on that endpoint is the AUTHENTICATED account\'s and is never ' +
|
|
55
|
+
'used. Favorites (breweries liked on Untappd via untappd_sync_brewery_likes, derived from the cached history, or ' +
|
|
56
|
+
'added with untappd_favorite_add) are flagged on matched ' +
|
|
57
|
+
`venues, the brewery and beers; prefer_favorites (default true) lifts wishlisted beers (+${WISHLIST_BOOST}) and a ` +
|
|
58
|
+
`favorite brewery's beers (+${FAVORITE_BREWERY_BOOST}); and a stop within near_favorite_radius_m (default ` +
|
|
59
|
+
`${NEAR_FAVORITE_RADIUS_M} m) of a favorite venue gets near_favorites + a "Near a favorite" hint. For a GROUP pass ` +
|
|
60
|
+
'usernames + mode ("none_had" default / "any_not_had"). Returns has-had and venue freshness blocks.',
|
|
61
|
+
annotations: toolAnnotations({
|
|
62
|
+
title: 'Check a list of trip stops against a user’s history (cache)',
|
|
63
|
+
readOnly: true,
|
|
64
|
+
idempotent: true,
|
|
65
|
+
openWorld: true,
|
|
66
|
+
}),
|
|
67
|
+
inputSchema: z.object({
|
|
68
|
+
username: UsernameArg,
|
|
69
|
+
usernames: z
|
|
70
|
+
.array(z.string().min(1))
|
|
71
|
+
.min(1)
|
|
72
|
+
.max(MAX_GROUP_USERS)
|
|
73
|
+
.optional()
|
|
74
|
+
.describe(`Check a GROUP (1–${MAX_GROUP_USERS}) instead of one user; use instead of username`),
|
|
75
|
+
mode: z
|
|
76
|
+
.enum(MULTI_USER_MODES)
|
|
77
|
+
.optional()
|
|
78
|
+
.describe('With usernames: "none_had" (default) lists beers new to everyone; "any_not_had" beers someone still needs'),
|
|
79
|
+
places: z.array(PlaceArg).min(1).max(25).describe('The stops (1–25); each needs venue_id, name, lat+lng, or brewery_id'),
|
|
80
|
+
top_n: z.number().int().min(1).max(10).optional().describe(`Not-had beers listed per brewery stop (default ${TRIP_TOP_N})`),
|
|
81
|
+
style: z.string().min(1).optional().describe('Case-insensitive substring filter on the beer style (e.g. "ipa")'),
|
|
82
|
+
prefer_favorites: z
|
|
83
|
+
.boolean()
|
|
84
|
+
.optional()
|
|
85
|
+
.describe(`Boost wishlisted beers (+${WISHLIST_BOOST}) and favorite-brewery beers (+${FAVORITE_BREWERY_BOOST}) (default true)`),
|
|
86
|
+
near_favorite_radius_m: z
|
|
87
|
+
.number()
|
|
88
|
+
.positive()
|
|
89
|
+
.max(50_000)
|
|
90
|
+
.optional()
|
|
91
|
+
.describe(`Radius for the "near a favorite" hint in metres (default ${NEAR_FAVORITE_RADIUS_M})`),
|
|
92
|
+
include_out_of_production: z.boolean().optional().describe('Also rank beers Untappd marks out of production (default false)'),
|
|
93
|
+
max_matches: z.number().int().min(1).max(20).optional().describe('Most matched venues listed per user per stop (default 5)'),
|
|
94
|
+
api_budget: z
|
|
95
|
+
.number()
|
|
96
|
+
.int()
|
|
97
|
+
.min(0)
|
|
98
|
+
.max(25)
|
|
99
|
+
.optional()
|
|
100
|
+
.describe(`Max brewery/beer_list calls for missing or stale lists (default ${TRIP_DEFAULT_API_BUDGET}; 0 = cache only)`),
|
|
101
|
+
}),
|
|
102
|
+
}, async (args) => {
|
|
103
|
+
const { username, usernames, mode, places, top_n, style, prefer_favorites, near_favorite_radius_m, include_out_of_production, max_matches, api_budget } = args;
|
|
104
|
+
places.forEach((p, i) => {
|
|
105
|
+
const hasGeo = p.lat !== undefined || p.lng !== undefined;
|
|
106
|
+
if (hasGeo && (p.lat === undefined || p.lng === undefined)) {
|
|
107
|
+
throw createHelpfulError(`Place ${i + 1}: lat and lng must be given together.`, { hint: 'Pass both, or match by name / venue_id instead.' });
|
|
108
|
+
}
|
|
109
|
+
if (p.venue_id === undefined && p.name === undefined && !hasGeo && p.brewery_id === undefined) {
|
|
110
|
+
throw createHelpfulError(`Place ${i + 1} has nothing to match on.`, {
|
|
111
|
+
hint: 'Give venue_id, a name (optionally with city/state), lat + lng, or brewery_id.',
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
const group = usernames !== undefined && usernames.length > 0;
|
|
116
|
+
const users = resolveUsers(username, usernames, client.loginName);
|
|
117
|
+
const groupMode = mode ?? DEFAULT_MULTI_USER_MODE;
|
|
118
|
+
const cache = cacheProvider();
|
|
119
|
+
const topN = top_n ?? TRIP_TOP_N;
|
|
120
|
+
const preferFavorites = prefer_favorites ?? true;
|
|
121
|
+
const nearRadius = near_favorite_radius_m ?? NEAR_FAVORITE_RADIUS_M;
|
|
122
|
+
const maxMatches = max_matches ?? 5;
|
|
123
|
+
const budget = api_budget ?? TRIP_DEFAULT_API_BUDGET;
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
// 1. Brewery lists: cached when fresh; a missing/stale one is fetched within the budget.
|
|
126
|
+
const lists = new Map();
|
|
127
|
+
let apiCalls = 0;
|
|
128
|
+
let rateStop;
|
|
129
|
+
for (const id of [...new Set(places.map((p) => p.brewery_id).filter((x) => x !== undefined))]) {
|
|
130
|
+
const cached = await cache.getBreweryBeerList(id);
|
|
131
|
+
const stale = cached !== undefined && now - Date.parse(cached.fetched_at) >= BREWERY_BEERS_TTL_MS;
|
|
132
|
+
const info = { list: cached, source: cached ? 'cache' : 'missing', stale };
|
|
133
|
+
lists.set(id, info);
|
|
134
|
+
if ((cached && !stale) || apiCalls >= budget || rateStop)
|
|
135
|
+
continue;
|
|
136
|
+
const verdict = await checkRateBudget(client, cache);
|
|
137
|
+
if (!verdict.ok) {
|
|
138
|
+
rateStop = stopFromVerdict(verdict);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
apiCalls++;
|
|
142
|
+
try {
|
|
143
|
+
await getBreweryBeers(client, cache, id, { limit: TRIP_LIST_LIMIT, refresh: stale });
|
|
144
|
+
lists.set(id, { list: await cache.getBreweryBeerList(id), source: 'api', stale: false });
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
if (e instanceof RateLimitError)
|
|
148
|
+
rateStop = stopFrom429(e);
|
|
149
|
+
else
|
|
150
|
+
info.error = messageOf(e);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// 2. Visits per place and user (cache only).
|
|
154
|
+
const breweryNames = new Map();
|
|
155
|
+
for (const id of lists.keys()) {
|
|
156
|
+
let name = lists.get(id).list?.brewery_name ?? null;
|
|
157
|
+
for (const u of users) {
|
|
158
|
+
if (name)
|
|
159
|
+
break;
|
|
160
|
+
name = (await cache.breweryHadStats(u, [id]))[0]?.brewery_name ?? null;
|
|
161
|
+
}
|
|
162
|
+
breweryNames.set(id, name);
|
|
163
|
+
}
|
|
164
|
+
const visits = [];
|
|
165
|
+
for (const p of places) {
|
|
166
|
+
const q = {
|
|
167
|
+
...(p.venue_id !== undefined ? { venue_ids: [p.venue_id] } : {}),
|
|
168
|
+
name: p.name ?? (p.venue_id === undefined && p.lat === undefined && p.brewery_id !== undefined ? (breweryNames.get(p.brewery_id) ?? undefined) : undefined),
|
|
169
|
+
city: p.city,
|
|
170
|
+
state: p.state,
|
|
171
|
+
lat: p.lat,
|
|
172
|
+
lng: p.lng,
|
|
173
|
+
radius_m: p.radius_m,
|
|
174
|
+
};
|
|
175
|
+
if (!q.venue_ids && q.name === undefined && q.lat === undefined) {
|
|
176
|
+
visits.push(null); // brewery stop whose name is unknown: cannot match
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const perUser = [];
|
|
180
|
+
for (const u of users)
|
|
181
|
+
perUser.push({ username: u, matches: await cache.findVisitedVenues(u, q) });
|
|
182
|
+
visits.push(perUser);
|
|
183
|
+
}
|
|
184
|
+
// 3. Favorite venues with known coordinates, for the near-a-favorite hint.
|
|
185
|
+
const favVenueIds = new Set();
|
|
186
|
+
for (const u of users)
|
|
187
|
+
for (const f of await listFavorites(cache, u, { kind: 'venue' }))
|
|
188
|
+
favVenueIds.add(f.id);
|
|
189
|
+
const favVenues = (await cache.getVenues([...favVenueIds])).filter((v) => v.lat !== null && v.lng !== null);
|
|
190
|
+
const placeVenues = await cache.getVenues(places.map((p) => p.venue_id).filter((x) => x !== undefined));
|
|
191
|
+
const placeVenueById = new Map(placeVenues.map((v) => [v.venue_id, v]));
|
|
192
|
+
// 4. Not-had candidates per brewery (cache only).
|
|
193
|
+
const style_ = style?.toLowerCase();
|
|
194
|
+
const candidates = new Map();
|
|
195
|
+
for (const [id, info] of lists) {
|
|
196
|
+
const rows = (info.list?.beers ?? []).filter((r) => (include_out_of_production || r.in_production !== false) && (style_ === undefined || (r.style?.toLowerCase().includes(style_) ?? false)));
|
|
197
|
+
candidates.set(id, rows);
|
|
198
|
+
}
|
|
199
|
+
const allBids = [...new Set([...candidates.values()].flat().map((r) => r.bid))];
|
|
200
|
+
const verdicts = await multiHasHad(cache, users, allBids, groupMode);
|
|
201
|
+
const wishlisted = new Set();
|
|
202
|
+
for (const u of users)
|
|
203
|
+
for (const w of await cache.getWishlist(u, allBids))
|
|
204
|
+
wishlisted.add(w.bid);
|
|
205
|
+
const favs = await favoriteLookup(cache, users, {
|
|
206
|
+
venue: [...visits.flatMap((v) => (v ?? []).flatMap((x) => x.matches.map((m) => m.venue_id))), ...favVenueIds, ...placeVenueById.keys()],
|
|
207
|
+
brewery: [...lists.keys()],
|
|
208
|
+
beer: allBids,
|
|
209
|
+
});
|
|
210
|
+
// 5. Assemble.
|
|
211
|
+
let visitedCount = 0;
|
|
212
|
+
let missingLists = 0;
|
|
213
|
+
let staleLists = 0;
|
|
214
|
+
const out = places.map((p, i) => {
|
|
215
|
+
const perUser = visits[i];
|
|
216
|
+
const visited = perUser ? perUser.some((x) => x.matches.length > 0) : null;
|
|
217
|
+
if (visited)
|
|
218
|
+
visitedCount++;
|
|
219
|
+
const matchedIds = new Set([...(perUser ?? []).flatMap((x) => x.matches.map((m) => m.venue_id)), ...(p.venue_id ? [p.venue_id] : [])]);
|
|
220
|
+
const res = {
|
|
221
|
+
...(p.label !== undefined ? { label: p.label } : {}),
|
|
222
|
+
place: { ...p, label: undefined },
|
|
223
|
+
visited,
|
|
224
|
+
...(perUser
|
|
225
|
+
? {
|
|
226
|
+
visits: perUser.map(({ username: u, matches }) => ({
|
|
227
|
+
username: u,
|
|
228
|
+
visited: matches.length > 0,
|
|
229
|
+
total_visits: matches.reduce((n, m) => n + (m.total_count ?? 0), 0),
|
|
230
|
+
first_visit: minIso(matches.map((m) => m.first_visit)),
|
|
231
|
+
recent_visit: maxIso(matches.map((m) => m.recent_visit)),
|
|
232
|
+
match_count: matches.length,
|
|
233
|
+
matches: matches.slice(0, maxMatches).map((m) => ({
|
|
234
|
+
venue_id: m.venue_id,
|
|
235
|
+
name: m.name,
|
|
236
|
+
city: m.city,
|
|
237
|
+
state: m.state,
|
|
238
|
+
...(m.is_duplicate ? { is_duplicate: true } : {}),
|
|
239
|
+
...(m.distance_m !== undefined ? { distance_m: m.distance_m } : {}),
|
|
240
|
+
first_visit: m.first_visit,
|
|
241
|
+
recent_visit: m.recent_visit,
|
|
242
|
+
total_count: m.total_count,
|
|
243
|
+
favorite: favs.get('venue', m.venue_id),
|
|
244
|
+
})),
|
|
245
|
+
})),
|
|
246
|
+
}
|
|
247
|
+
: { visit_note: 'No venue name is known for this brewery yet — pass name (and city) to check visits.' }),
|
|
248
|
+
favorite_venue: [...matchedIds].some((id) => favs.isFavorite('venue', id)),
|
|
249
|
+
};
|
|
250
|
+
if (p.brewery_id !== undefined) {
|
|
251
|
+
const id = p.brewery_id;
|
|
252
|
+
const info = lists.get(id);
|
|
253
|
+
if (info.source === 'missing')
|
|
254
|
+
missingLists++;
|
|
255
|
+
if (info.stale)
|
|
256
|
+
staleLists++;
|
|
257
|
+
const rows = candidates.get(id);
|
|
258
|
+
const notHad = rows.filter((r) => verdicts.get(r.bid)?.qualifies);
|
|
259
|
+
const breweryFav = favs.get('brewery', id);
|
|
260
|
+
const boostsOf = (r) => {
|
|
261
|
+
if (!preferFavorites)
|
|
262
|
+
return [];
|
|
263
|
+
const b = [];
|
|
264
|
+
if (wishlisted.has(r.bid))
|
|
265
|
+
b.push('wishlist');
|
|
266
|
+
if (breweryFav.is_favorite)
|
|
267
|
+
b.push('favorite_brewery');
|
|
268
|
+
return b;
|
|
269
|
+
};
|
|
270
|
+
const scoreOf = (r) => ratingOf(r) + boostsOf(r).reduce((n, b) => n + (b === 'wishlist' ? WISHLIST_BOOST : FAVORITE_BREWERY_BOOST), 0);
|
|
271
|
+
const ranked = notHad
|
|
272
|
+
.slice()
|
|
273
|
+
.sort((a, b) => scoreOf(b) - scoreOf(a) || ratingOf(b) - ratingOf(a) || a.bid - b.bid)
|
|
274
|
+
.slice(0, topN)
|
|
275
|
+
.map((r) => {
|
|
276
|
+
const boosts = boostsOf(r);
|
|
277
|
+
const beerFav = favs.get('beer', r.bid);
|
|
278
|
+
const v = verdicts.get(r.bid);
|
|
279
|
+
return {
|
|
280
|
+
bid: r.bid,
|
|
281
|
+
name: r.beer_name,
|
|
282
|
+
style: r.style,
|
|
283
|
+
abv: r.abv,
|
|
284
|
+
rating: r.rating_score,
|
|
285
|
+
rating_count: r.rating_count,
|
|
286
|
+
...(r.in_production === false ? { in_production: false } : {}),
|
|
287
|
+
...(boosts.length ? { score: Number(scoreOf(r).toFixed(3)), boosted_by: boosts } : {}),
|
|
288
|
+
...(wishlisted.has(r.bid) ? { wishlisted: true } : {}),
|
|
289
|
+
...(beerFav.is_favorite ? { favorite: beerFav } : {}),
|
|
290
|
+
...(group ? { not_had_by: v.not_had_by, had_by: v.had_by.map((x) => x.username) } : {}),
|
|
291
|
+
};
|
|
292
|
+
});
|
|
293
|
+
res.brewery = {
|
|
294
|
+
brewery_id: id,
|
|
295
|
+
brewery_name: breweryNames.get(id) ?? null,
|
|
296
|
+
source: info.source,
|
|
297
|
+
stale: info.stale,
|
|
298
|
+
fetched_at: info.list?.fetched_at ?? null,
|
|
299
|
+
...(info.error ? { error: info.error } : {}),
|
|
300
|
+
beers_in_list: info.list?.beers.length ?? 0,
|
|
301
|
+
total_count: info.list?.total_count ?? null,
|
|
302
|
+
not_had_in_list: notHad.length,
|
|
303
|
+
favorite: breweryFav,
|
|
304
|
+
};
|
|
305
|
+
res.top_not_had = ranked;
|
|
306
|
+
}
|
|
307
|
+
return res;
|
|
308
|
+
});
|
|
309
|
+
// beers_had per brewery stop (async, so outside the map).
|
|
310
|
+
for (let i = 0; i < places.length; i++) {
|
|
311
|
+
const id = places[i].brewery_id;
|
|
312
|
+
if (id === undefined)
|
|
313
|
+
continue;
|
|
314
|
+
const brewery = out[i].brewery;
|
|
315
|
+
const counts = {};
|
|
316
|
+
for (const u of users)
|
|
317
|
+
counts[u] = (await cache.breweryHadStats(u, [id]))[0]?.beers ?? 0;
|
|
318
|
+
if (group)
|
|
319
|
+
brewery.beers_had_by = counts;
|
|
320
|
+
else
|
|
321
|
+
brewery.beers_had = counts[users[0]];
|
|
322
|
+
}
|
|
323
|
+
// Near-a-favorite hint: centre = the stop's lat/lng, else its first matched venue with coordinates.
|
|
324
|
+
for (let i = 0; i < places.length; i++) {
|
|
325
|
+
const p = places[i];
|
|
326
|
+
let centre = p.lat !== undefined && p.lng !== undefined ? { lat: p.lat, lng: p.lng } : null;
|
|
327
|
+
const matches = (visits[i] ?? []).flatMap((x) => x.matches);
|
|
328
|
+
if (!centre && p.venue_id !== undefined) {
|
|
329
|
+
const v = placeVenueById.get(p.venue_id);
|
|
330
|
+
if (v && v.lat !== null && v.lng !== null)
|
|
331
|
+
centre = { lat: v.lat, lng: v.lng };
|
|
332
|
+
}
|
|
333
|
+
if (!centre) {
|
|
334
|
+
const m = matches.find((x) => x.lat !== null && x.lng !== null);
|
|
335
|
+
if (m)
|
|
336
|
+
centre = { lat: m.lat, lng: m.lng };
|
|
337
|
+
}
|
|
338
|
+
if (!centre)
|
|
339
|
+
continue;
|
|
340
|
+
const exclude = new Set([...matches.map((m) => m.venue_id), ...(p.venue_id ? [p.venue_id] : [])]);
|
|
341
|
+
const near = favVenues
|
|
342
|
+
.filter((v) => !exclude.has(v.venue_id))
|
|
343
|
+
.map((v) => ({ v, d: Math.round(haversineM(centre.lat, centre.lng, v.lat, v.lng)) }))
|
|
344
|
+
.filter((x) => x.d <= nearRadius)
|
|
345
|
+
.sort((a, b) => a.d - b.d)
|
|
346
|
+
.slice(0, MAX_NEAR_FAVORITES);
|
|
347
|
+
if (near.length === 0)
|
|
348
|
+
continue;
|
|
349
|
+
out[i].near_favorites = near.map(({ v, d }) => ({
|
|
350
|
+
venue_id: v.venue_id,
|
|
351
|
+
name: v.name,
|
|
352
|
+
city: v.city,
|
|
353
|
+
distance_m: d,
|
|
354
|
+
favorite: favs.get('venue', v.venue_id),
|
|
355
|
+
}));
|
|
356
|
+
out[i].hint = `Near a favorite: ${near.map(({ v, d }) => `${v.name ?? `venue ${v.venue_id}`} (${d} m)`).join(', ')}`;
|
|
357
|
+
}
|
|
358
|
+
const partial = missingLists > 0 || staleLists > 0;
|
|
359
|
+
const venueFresh = group
|
|
360
|
+
? Object.fromEntries(await Promise.all(users.map(async (u) => [u, await venueFreshness(cache, u)])))
|
|
361
|
+
: await venueFreshness(cache, users[0]);
|
|
362
|
+
return minifiedResult({
|
|
363
|
+
...(group ? { usernames: users, mode: groupMode } : { username: users[0] }),
|
|
364
|
+
places: out,
|
|
365
|
+
summary: {
|
|
366
|
+
places: places.length,
|
|
367
|
+
visited: visitedCount,
|
|
368
|
+
prefer_favorites: preferFavorites,
|
|
369
|
+
near_favorite_radius_m: nearRadius,
|
|
370
|
+
api_calls_used: apiCalls,
|
|
371
|
+
...(missingLists ? { brewery_lists_missing: missingLists } : {}),
|
|
372
|
+
...(staleLists ? { brewery_lists_stale: staleLists } : {}),
|
|
373
|
+
partial,
|
|
374
|
+
another_run_needed: partial,
|
|
375
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
376
|
+
...(rateStop ? { rate_limit: rateStop, note: resumeAdvice(rateStop, 'untappd_trip_check') } : {}),
|
|
377
|
+
},
|
|
378
|
+
freshness: {
|
|
379
|
+
beers: group ? await groupFreshness(cache, users) : await freshness(cache, users[0]),
|
|
380
|
+
venues: group ? { by_user: venueFresh } : venueFresh,
|
|
381
|
+
},
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
}
|
package/dist/tools/user.js
CHANGED
|
@@ -16,6 +16,27 @@ function resolveUser(username, loginName) {
|
|
|
16
16
|
}
|
|
17
17
|
return encodeURIComponent(u);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* user/venues sort values. Untappd silently IGNORES a value it doesn't know
|
|
21
|
+
* (the old 'name' / 'checkin' / 'highest_rated' never sorted anything), so the
|
|
22
|
+
* friendly aliases map onto the real keys. There is no rating sort for venues:
|
|
23
|
+
* 'highest_rated' is kept only so pre-existing callers don't start failing
|
|
24
|
+
* validation, as a DEPRECATED alias of highest_checkin.
|
|
25
|
+
*/
|
|
26
|
+
export const USER_VENUE_SORT_KEYS = {
|
|
27
|
+
date: 'date',
|
|
28
|
+
recent: 'recent',
|
|
29
|
+
date_asc: 'date_asc',
|
|
30
|
+
venue_name_asc: 'venue_name_asc',
|
|
31
|
+
venue_name_desc: 'venue_name_desc',
|
|
32
|
+
highest_checkin: 'highest_checkin',
|
|
33
|
+
lowest_checkin: 'lowest_checkin',
|
|
34
|
+
name: 'venue_name_asc',
|
|
35
|
+
checkin: 'highest_checkin',
|
|
36
|
+
/** @deprecated Untappd has no venue rating sort; kept for old callers. */
|
|
37
|
+
highest_rated: 'highest_checkin',
|
|
38
|
+
};
|
|
39
|
+
const USER_VENUE_SORTS = Object.keys(USER_VENUE_SORT_KEYS);
|
|
19
40
|
export function registerUserTools(server, client) {
|
|
20
41
|
server.registerTool('untappd_user_info', {
|
|
21
42
|
title: 'Get Untappd user profile',
|
|
@@ -34,7 +55,9 @@ export function registerUserTools(server, client) {
|
|
|
34
55
|
server.registerTool('untappd_user_checkins', {
|
|
35
56
|
title: 'Get Untappd user check-ins',
|
|
36
57
|
description: "Get a user's recent check-ins (most recent first): the beer, rating, comment, venue, and toasts/comments. " +
|
|
37
|
-
'Page backwards with max_id (the pagination.max_id from a prior call).
|
|
58
|
+
'Page backwards with max_id (the pagination.max_id from a prior call). Only your OWN history pages fully: for ' +
|
|
59
|
+
'any other user Untappd returns just the ~50 most recent check-ins and ignores max_id — use untappd_user_beers ' +
|
|
60
|
+
'(has had) or untappd_user_venues (has been) for their full history. Omit username for your own. Read-only.',
|
|
38
61
|
annotations: toolAnnotations({ title: 'Get Untappd user check-ins', readOnly: true, idempotent: true, openWorld: true }),
|
|
39
62
|
inputSchema: z.object({
|
|
40
63
|
username: UsernameArg,
|
|
@@ -114,20 +137,28 @@ export function registerUserTools(server, client) {
|
|
|
114
137
|
});
|
|
115
138
|
server.registerTool('untappd_user_venues', {
|
|
116
139
|
title: 'Get venues a user has checked in at',
|
|
117
|
-
description: "Get the venues a user has checked in at, most recent first, with per-venue
|
|
118
|
-
'
|
|
140
|
+
description: "Get the venues a user has checked in at, most recent first, with per-venue first/recent visit and " +
|
|
141
|
+
'check-in counts. Pages fully for any public/friend account (unlike user check-ins, which stop at ~50 for ' +
|
|
142
|
+
'other users). Untappd returns at most 25 venues per page whatever limit you ask for — advance by the ' +
|
|
143
|
+
"returned pagination offset. Omit username for your own account. Read-only.",
|
|
119
144
|
annotations: toolAnnotations({ title: 'Get venues a user has checked in at', readOnly: true, idempotent: true, openWorld: true }),
|
|
120
145
|
inputSchema: z.object({
|
|
121
146
|
username: UsernameArg,
|
|
122
|
-
limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50
|
|
147
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max venues (1–50; Untappd caps a page at 25)'),
|
|
123
148
|
offset: z.number().int().min(0).optional().describe('Result offset for paging (default 0)'),
|
|
124
149
|
sort: z
|
|
125
|
-
.enum(
|
|
150
|
+
.enum(USER_VENUE_SORTS)
|
|
126
151
|
.optional()
|
|
127
|
-
.describe('Sort order (default most recent)'
|
|
152
|
+
.describe('Sort order (default most recent). Untappd keys: date, recent, date_asc, venue_name_asc, venue_name_desc, ' +
|
|
153
|
+
'highest_checkin, lowest_checkin. Aliases: name = venue_name_asc, checkin = highest_checkin. ' +
|
|
154
|
+
'highest_rated is deprecated (Untappd has no venue rating sort) and is sent as highest_checkin.'),
|
|
128
155
|
}),
|
|
129
156
|
}, async ({ username, limit, offset, sort }) => {
|
|
130
|
-
const data = await client.get(`/user/venues/${resolveUser(username, client.loginName)}`, {
|
|
157
|
+
const data = await client.get(`/user/venues/${resolveUser(username, client.loginName)}`, {
|
|
158
|
+
limit,
|
|
159
|
+
offset,
|
|
160
|
+
sort: sort === undefined ? undefined : USER_VENUE_SORT_KEYS[sort],
|
|
161
|
+
});
|
|
131
162
|
return minifiedResult(data);
|
|
132
163
|
});
|
|
133
164
|
server.registerTool('untappd_pending_friends', {
|