untappd-mcp 1.3.0 → 1.4.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 +54 -0
- package/SKILL.md +29 -0
- package/dist/bundle.js +712 -48
- package/dist/cache/db.js +65 -0
- package/dist/cache/store.js +333 -0
- package/dist/cache/sync.js +202 -0
- package/dist/index.js +13 -0
- package/dist/tools/cache.js +168 -0
- package/dist/tools/diagnostics.js +33 -0
- package/dist/tools/utilities.js +10 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
package/dist/bundle.js
CHANGED
|
@@ -101,7 +101,7 @@ var require_code = __commonJS({
|
|
|
101
101
|
}
|
|
102
102
|
exports._ = _;
|
|
103
103
|
var plus = new _Code("+");
|
|
104
|
-
function
|
|
104
|
+
function str2(strs, ...args) {
|
|
105
105
|
const expr = [safeStringify(strs[0])];
|
|
106
106
|
let i = 0;
|
|
107
107
|
while (i < args.length) {
|
|
@@ -112,7 +112,7 @@ var require_code = __commonJS({
|
|
|
112
112
|
optimize(expr);
|
|
113
113
|
return new _Code(expr);
|
|
114
114
|
}
|
|
115
|
-
exports.str =
|
|
115
|
+
exports.str = str2;
|
|
116
116
|
function addCodeArg(code, arg) {
|
|
117
117
|
if (arg instanceof _Code)
|
|
118
118
|
code.push(...arg._items);
|
|
@@ -155,7 +155,7 @@ var require_code = __commonJS({
|
|
|
155
155
|
return;
|
|
156
156
|
}
|
|
157
157
|
function strConcat(c1, c2) {
|
|
158
|
-
return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 :
|
|
158
|
+
return c2.emptyStr() ? c1 : c1.emptyStr() ? c2 : str2`${c1}${c2}`;
|
|
159
159
|
}
|
|
160
160
|
exports.strConcat = strConcat;
|
|
161
161
|
function interpolate(x) {
|
|
@@ -1117,22 +1117,22 @@ var require_util = __commonJS({
|
|
|
1117
1117
|
return (0, codegen_1._)`${topSchemaRef}${schemaPath}${(0, codegen_1.getProperty)(keyword)}`;
|
|
1118
1118
|
}
|
|
1119
1119
|
exports.schemaRefOrVal = schemaRefOrVal;
|
|
1120
|
-
function unescapeFragment(
|
|
1121
|
-
return unescapeJsonPointer(decodeURIComponent(
|
|
1120
|
+
function unescapeFragment(str2) {
|
|
1121
|
+
return unescapeJsonPointer(decodeURIComponent(str2));
|
|
1122
1122
|
}
|
|
1123
1123
|
exports.unescapeFragment = unescapeFragment;
|
|
1124
|
-
function escapeFragment(
|
|
1125
|
-
return encodeURIComponent(escapeJsonPointer(
|
|
1124
|
+
function escapeFragment(str2) {
|
|
1125
|
+
return encodeURIComponent(escapeJsonPointer(str2));
|
|
1126
1126
|
}
|
|
1127
1127
|
exports.escapeFragment = escapeFragment;
|
|
1128
|
-
function escapeJsonPointer(
|
|
1129
|
-
if (typeof
|
|
1130
|
-
return `${
|
|
1131
|
-
return
|
|
1128
|
+
function escapeJsonPointer(str2) {
|
|
1129
|
+
if (typeof str2 == "number")
|
|
1130
|
+
return `${str2}`;
|
|
1131
|
+
return str2.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1132
1132
|
}
|
|
1133
1133
|
exports.escapeJsonPointer = escapeJsonPointer;
|
|
1134
|
-
function unescapeJsonPointer(
|
|
1135
|
-
return
|
|
1134
|
+
function unescapeJsonPointer(str2) {
|
|
1135
|
+
return str2.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1136
1136
|
}
|
|
1137
1137
|
exports.unescapeJsonPointer = unescapeJsonPointer;
|
|
1138
1138
|
function eachItem(xs, f) {
|
|
@@ -2157,8 +2157,8 @@ var require_json_schema_traverse = __commonJS({
|
|
|
2157
2157
|
post(schema, jsonPtr, rootSchema, parentJsonPtr, parentKeyword, parentSchema, keyIndex);
|
|
2158
2158
|
}
|
|
2159
2159
|
}
|
|
2160
|
-
function escapeJsonPtr(
|
|
2161
|
-
return
|
|
2160
|
+
function escapeJsonPtr(str2) {
|
|
2161
|
+
return str2.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
2162
2162
|
}
|
|
2163
2163
|
}
|
|
2164
2164
|
});
|
|
@@ -3222,10 +3222,10 @@ var require_utils = __commonJS({
|
|
|
3222
3222
|
return { host, isIPV6: false };
|
|
3223
3223
|
}
|
|
3224
3224
|
}
|
|
3225
|
-
function findToken(
|
|
3225
|
+
function findToken(str2, token) {
|
|
3226
3226
|
let ind = 0;
|
|
3227
|
-
for (let i = 0; i <
|
|
3228
|
-
if (
|
|
3227
|
+
for (let i = 0; i < str2.length; i++) {
|
|
3228
|
+
if (str2[i] === token) ind++;
|
|
3229
3229
|
}
|
|
3230
3230
|
return ind;
|
|
3231
3231
|
}
|
|
@@ -3962,7 +3962,7 @@ var require_core = __commonJS({
|
|
|
3962
3962
|
var util_1 = require_util();
|
|
3963
3963
|
var $dataRefSchema = require_data();
|
|
3964
3964
|
var uri_1 = require_uri();
|
|
3965
|
-
var defaultRegExp = (
|
|
3965
|
+
var defaultRegExp = (str2, flags) => new RegExp(str2, flags);
|
|
3966
3966
|
defaultRegExp.code = "new RegExp";
|
|
3967
3967
|
var META_IGNORE_OPTIONS = ["removeAdditional", "useDefaults", "coerceTypes"];
|
|
3968
3968
|
var EXT_SCOPE_NAMES = /* @__PURE__ */ new Set([
|
|
@@ -4757,16 +4757,16 @@ var require_ucs2length = __commonJS({
|
|
|
4757
4757
|
"node_modules/ajv/dist/runtime/ucs2length.js"(exports) {
|
|
4758
4758
|
"use strict";
|
|
4759
4759
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4760
|
-
function ucs2length(
|
|
4761
|
-
const len =
|
|
4760
|
+
function ucs2length(str2) {
|
|
4761
|
+
const len = str2.length;
|
|
4762
4762
|
let length = 0;
|
|
4763
4763
|
let pos = 0;
|
|
4764
4764
|
let value;
|
|
4765
4765
|
while (pos < len) {
|
|
4766
4766
|
length++;
|
|
4767
|
-
value =
|
|
4767
|
+
value = str2.charCodeAt(pos++);
|
|
4768
4768
|
if (value >= 55296 && value <= 56319 && pos < len) {
|
|
4769
|
-
value =
|
|
4769
|
+
value = str2.charCodeAt(pos);
|
|
4770
4770
|
if ((value & 64512) === 56320)
|
|
4771
4771
|
pos++;
|
|
4772
4772
|
}
|
|
@@ -6649,8 +6649,8 @@ var require_formats = __commonJS({
|
|
|
6649
6649
|
}
|
|
6650
6650
|
var DATE = /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
|
|
6651
6651
|
var DAYS = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
|
|
6652
|
-
function date5(
|
|
6653
|
-
const matches = DATE.exec(
|
|
6652
|
+
function date5(str2) {
|
|
6653
|
+
const matches = DATE.exec(str2);
|
|
6654
6654
|
if (!matches)
|
|
6655
6655
|
return false;
|
|
6656
6656
|
const year = +matches[1];
|
|
@@ -6669,8 +6669,8 @@ var require_formats = __commonJS({
|
|
|
6669
6669
|
}
|
|
6670
6670
|
var TIME = /^(\d\d):(\d\d):(\d\d(?:\.\d+)?)(z|([+-])(\d\d)(?::?(\d\d))?)?$/i;
|
|
6671
6671
|
function getTime(strictTimeZone) {
|
|
6672
|
-
return function time3(
|
|
6673
|
-
const matches = TIME.exec(
|
|
6672
|
+
return function time3(str2) {
|
|
6673
|
+
const matches = TIME.exec(str2);
|
|
6674
6674
|
if (!matches)
|
|
6675
6675
|
return false;
|
|
6676
6676
|
const hr = +matches[1];
|
|
@@ -6716,8 +6716,8 @@ var require_formats = __commonJS({
|
|
|
6716
6716
|
var DATE_TIME_SEPARATOR = /t|\s/i;
|
|
6717
6717
|
function getDateTime(strictTimeZone) {
|
|
6718
6718
|
const time3 = getTime(strictTimeZone);
|
|
6719
|
-
return function date_time(
|
|
6720
|
-
const dateTime =
|
|
6719
|
+
return function date_time(str2) {
|
|
6720
|
+
const dateTime = str2.split(DATE_TIME_SEPARATOR);
|
|
6721
6721
|
return dateTime.length === 2 && date5(dateTime[0]) && time3(dateTime[1]);
|
|
6722
6722
|
};
|
|
6723
6723
|
}
|
|
@@ -6742,13 +6742,13 @@ var require_formats = __commonJS({
|
|
|
6742
6742
|
}
|
|
6743
6743
|
var NOT_URI_FRAGMENT = /\/|:/;
|
|
6744
6744
|
var URI = /^(?:[a-z][a-z0-9+\-.]*:)(?:\/?\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:]|%[0-9a-f]{2})*@)?(?:\[(?:(?:(?:(?:[0-9a-f]{1,4}:){6}|::(?:[0-9a-f]{1,4}:){5}|(?:[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){4}|(?:(?:[0-9a-f]{1,4}:){0,1}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){3}|(?:(?:[0-9a-f]{1,4}:){0,2}[0-9a-f]{1,4})?::(?:[0-9a-f]{1,4}:){2}|(?:(?:[0-9a-f]{1,4}:){0,3}[0-9a-f]{1,4})?::[0-9a-f]{1,4}:|(?:(?:[0-9a-f]{1,4}:){0,4}[0-9a-f]{1,4})?::)(?:[0-9a-f]{1,4}:[0-9a-f]{1,4}|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?))|(?:(?:[0-9a-f]{1,4}:){0,5}[0-9a-f]{1,4})?::[0-9a-f]{1,4}|(?:(?:[0-9a-f]{1,4}:){0,6}[0-9a-f]{1,4})?::)|[Vv][0-9a-f]+\.[a-z0-9\-._~!$&'()*+,;=:]+)\]|(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d\d?)|(?:[a-z0-9\-._~!$&'()*+,;=]|%[0-9a-f]{2})*)(?::\d*)?(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*|\/(?:(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)?|(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})+(?:\/(?:[a-z0-9\-._~!$&'()*+,;=:@]|%[0-9a-f]{2})*)*)(?:\?(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?(?:#(?:[a-z0-9\-._~!$&'()*+,;=:@/?]|%[0-9a-f]{2})*)?$/i;
|
|
6745
|
-
function uri(
|
|
6746
|
-
return NOT_URI_FRAGMENT.test(
|
|
6745
|
+
function uri(str2) {
|
|
6746
|
+
return NOT_URI_FRAGMENT.test(str2) && URI.test(str2);
|
|
6747
6747
|
}
|
|
6748
6748
|
var BYTE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/gm;
|
|
6749
|
-
function byte(
|
|
6749
|
+
function byte(str2) {
|
|
6750
6750
|
BYTE.lastIndex = 0;
|
|
6751
|
-
return BYTE.test(
|
|
6751
|
+
return BYTE.test(str2);
|
|
6752
6752
|
}
|
|
6753
6753
|
var MIN_INT32 = -(2 ** 31);
|
|
6754
6754
|
var MAX_INT32 = 2 ** 31 - 1;
|
|
@@ -6762,11 +6762,11 @@ var require_formats = __commonJS({
|
|
|
6762
6762
|
return true;
|
|
6763
6763
|
}
|
|
6764
6764
|
var Z_ANCHOR = /[^\\]\\Z/;
|
|
6765
|
-
function regex(
|
|
6766
|
-
if (Z_ANCHOR.test(
|
|
6765
|
+
function regex(str2) {
|
|
6766
|
+
if (Z_ANCHOR.test(str2))
|
|
6767
6767
|
return false;
|
|
6768
6768
|
try {
|
|
6769
|
-
new RegExp(
|
|
6769
|
+
new RegExp(str2);
|
|
6770
6770
|
return true;
|
|
6771
6771
|
} catch (e) {
|
|
6772
6772
|
return false;
|
|
@@ -11306,14 +11306,14 @@ function promiseAllObject(promisesObj) {
|
|
|
11306
11306
|
}
|
|
11307
11307
|
function randomString(length = 10) {
|
|
11308
11308
|
const chars = "abcdefghijklmnopqrstuvwxyz";
|
|
11309
|
-
let
|
|
11309
|
+
let str2 = "";
|
|
11310
11310
|
for (let i = 0; i < length; i++) {
|
|
11311
|
-
|
|
11311
|
+
str2 += chars[Math.floor(Math.random() * chars.length)];
|
|
11312
11312
|
}
|
|
11313
|
-
return
|
|
11313
|
+
return str2;
|
|
11314
11314
|
}
|
|
11315
|
-
function esc(
|
|
11316
|
-
return JSON.stringify(
|
|
11315
|
+
function esc(str2) {
|
|
11316
|
+
return JSON.stringify(str2);
|
|
11317
11317
|
}
|
|
11318
11318
|
function slugify(input) {
|
|
11319
11319
|
return input.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -11427,8 +11427,8 @@ var primitiveTypes = /* @__PURE__ */ new Set([
|
|
|
11427
11427
|
"symbol",
|
|
11428
11428
|
"undefined"
|
|
11429
11429
|
]);
|
|
11430
|
-
function escapeRegex(
|
|
11431
|
-
return
|
|
11430
|
+
function escapeRegex(str2) {
|
|
11431
|
+
return str2.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
11432
11432
|
}
|
|
11433
11433
|
function clone(inst, def, params) {
|
|
11434
11434
|
const cl = new inst._zod.constr(def ?? inst._zod.def);
|
|
@@ -31067,8 +31067,8 @@ function redactSecrets(text) {
|
|
|
31067
31067
|
return text.replace(BEARER_RE, "$1[REDACTED]").replace(BASIC_AUTH_RE, "$1[REDACTED]").replace(SET_COOKIE_RE, "$1$2=[REDACTED]").replace(COOKIE_HEADER_RE, (_m, prefix, pairs) => `${prefix}${pairs.replace(/=[^;,\s]*/g, "=[REDACTED]")}`).replace(API_KEY_RE, "[REDACTED]").replace(QUERY_SECRET_RE, "$1[REDACTED]").replace(AWS_SIGV4_RE, "$1[REDACTED]").replace(JSON_SECRET_DQ_RE, "$1[REDACTED]$2").replace(JSON_SECRET_SQ_RE, "$1[REDACTED]$2").replace(JWT_RE, "[REDACTED]");
|
|
31068
31068
|
}
|
|
31069
31069
|
function truncateErrorMessage(text, max = DEFAULT_ERROR_MESSAGE_MAX) {
|
|
31070
|
-
const
|
|
31071
|
-
const redacted = redactSecrets(
|
|
31070
|
+
const str2 = text === null || text === void 0 ? "" : String(text);
|
|
31071
|
+
const redacted = redactSecrets(str2);
|
|
31072
31072
|
if (redacted.length <= max)
|
|
31073
31073
|
return redacted;
|
|
31074
31074
|
return `${redacted.slice(0, max)}\u2026 [truncated]`;
|
|
@@ -31184,7 +31184,7 @@ function toolAnnotations(opts = {}) {
|
|
|
31184
31184
|
}
|
|
31185
31185
|
|
|
31186
31186
|
// src/version.ts
|
|
31187
|
-
var VERSION = "1.
|
|
31187
|
+
var VERSION = "1.4.0";
|
|
31188
31188
|
|
|
31189
31189
|
// src/client.ts
|
|
31190
31190
|
import { dirname, join } from "path";
|
|
@@ -32333,21 +32333,46 @@ function registerCheckinTools(server, client2) {
|
|
|
32333
32333
|
);
|
|
32334
32334
|
}
|
|
32335
32335
|
|
|
32336
|
+
// src/tools/diagnostics.ts
|
|
32337
|
+
function registeredToolNames(server) {
|
|
32338
|
+
const reg = server._registeredTools;
|
|
32339
|
+
return reg ? Object.keys(reg).sort() : [];
|
|
32340
|
+
}
|
|
32341
|
+
function toolsHash(names) {
|
|
32342
|
+
let h = 2166136261;
|
|
32343
|
+
const s = names.join(",");
|
|
32344
|
+
for (let i = 0; i < s.length; i++) {
|
|
32345
|
+
h ^= s.charCodeAt(i);
|
|
32346
|
+
h = Math.imul(h, 16777619);
|
|
32347
|
+
}
|
|
32348
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
32349
|
+
}
|
|
32350
|
+
function toolInventory(server) {
|
|
32351
|
+
const names = registeredToolNames(server);
|
|
32352
|
+
return { tool_count: names.length, tools_hash: toolsHash(names), tools: names };
|
|
32353
|
+
}
|
|
32354
|
+
function logRegisteredTools(server, context) {
|
|
32355
|
+
const names = registeredToolNames(server);
|
|
32356
|
+
console.error(`[untappd-mcp] ${context}: ${names.length} tools registered [${toolsHash(names)}]: ${names.join(", ")}`);
|
|
32357
|
+
}
|
|
32358
|
+
|
|
32336
32359
|
// src/tools/utilities.ts
|
|
32337
32360
|
function registerUtilityTools(server, client2) {
|
|
32338
32361
|
server.registerTool(
|
|
32339
32362
|
"untappd_healthcheck",
|
|
32340
32363
|
{
|
|
32341
32364
|
title: "Untappd healthcheck",
|
|
32342
|
-
description: "Verify Untappd connectivity and that credentials are configured and can log in. Performs a lightweight authenticated request (your recent feed) and reports whether it succeeded. Read-only.",
|
|
32365
|
+
description: "Verify Untappd connectivity and that credentials are configured and can log in. Performs a lightweight authenticated request (your recent feed) and reports whether it succeeded, plus the running server version and the exact set of tools this build exposes (count, a stable hash, and their names) so you can confirm which build is live. Read-only.",
|
|
32343
32366
|
annotations: toolAnnotations({ title: "Untappd healthcheck", readOnly: true, idempotent: true, openWorld: true }),
|
|
32344
32367
|
inputSchema: {}
|
|
32345
32368
|
},
|
|
32346
32369
|
async () => {
|
|
32370
|
+
const build = { server_version: VERSION, ...toolInventory(server) };
|
|
32347
32371
|
if (!client2.configured) {
|
|
32348
32372
|
return textResult({
|
|
32349
32373
|
ok: false,
|
|
32350
32374
|
configured: false,
|
|
32375
|
+
...build,
|
|
32351
32376
|
note: "Untappd credentials are not set. Configure UNTAPPD_USERNAME, UNTAPPD_PASSWORD, UNTAPPD_CLIENT_ID, and UNTAPPD_CLIENT_SECRET."
|
|
32352
32377
|
});
|
|
32353
32378
|
}
|
|
@@ -32357,14 +32382,647 @@ function registerUtilityTools(server, client2) {
|
|
|
32357
32382
|
configured: true,
|
|
32358
32383
|
account: client2.loginName,
|
|
32359
32384
|
feed_reachable: feed?.checkins !== void 0,
|
|
32385
|
+
...build,
|
|
32360
32386
|
note: "Logged in to Untappd and fetched the friend feed successfully."
|
|
32361
32387
|
});
|
|
32362
32388
|
}
|
|
32363
32389
|
);
|
|
32364
32390
|
}
|
|
32365
32391
|
|
|
32392
|
+
// src/cache/store.ts
|
|
32393
|
+
function asDict2(v) {
|
|
32394
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
|
32395
|
+
}
|
|
32396
|
+
function num(v) {
|
|
32397
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
32398
|
+
}
|
|
32399
|
+
function str(v) {
|
|
32400
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
32401
|
+
}
|
|
32402
|
+
function mapCheckinRow(username, item) {
|
|
32403
|
+
const c = asDict2(item);
|
|
32404
|
+
const checkin_id = num(c.checkin_id);
|
|
32405
|
+
if (checkin_id === null) return null;
|
|
32406
|
+
const beer = asDict2(c.beer);
|
|
32407
|
+
const brewery = asDict2(c.brewery);
|
|
32408
|
+
const venue = asDict2(c.venue);
|
|
32409
|
+
return {
|
|
32410
|
+
checkin_id,
|
|
32411
|
+
username,
|
|
32412
|
+
bid: num(beer.bid),
|
|
32413
|
+
beer_name: str(beer.beer_name),
|
|
32414
|
+
brewery_id: num(brewery.brewery_id),
|
|
32415
|
+
brewery_name: str(brewery.brewery_name),
|
|
32416
|
+
beer_style: str(beer.beer_style),
|
|
32417
|
+
abv: num(beer.beer_abv),
|
|
32418
|
+
rating: num(c.rating_score),
|
|
32419
|
+
comment: str(c.checkin_comment),
|
|
32420
|
+
venue_id: num(venue.venue_id),
|
|
32421
|
+
venue_name: str(venue.venue_name),
|
|
32422
|
+
created_at: normaliseDate(c.created_at)
|
|
32423
|
+
};
|
|
32424
|
+
}
|
|
32425
|
+
function normaliseDate(v) {
|
|
32426
|
+
const s = str(v);
|
|
32427
|
+
if (!s) return null;
|
|
32428
|
+
const t = Date.parse(s);
|
|
32429
|
+
return Number.isNaN(t) ? s : new Date(t).toISOString();
|
|
32430
|
+
}
|
|
32431
|
+
var SCHEMA_STATEMENTS = [
|
|
32432
|
+
`CREATE TABLE IF NOT EXISTS checkins (
|
|
32433
|
+
checkin_id INTEGER PRIMARY KEY,
|
|
32434
|
+
username TEXT NOT NULL,
|
|
32435
|
+
bid INTEGER,
|
|
32436
|
+
beer_name TEXT,
|
|
32437
|
+
brewery_id INTEGER,
|
|
32438
|
+
brewery_name TEXT,
|
|
32439
|
+
beer_style TEXT,
|
|
32440
|
+
abv REAL,
|
|
32441
|
+
rating REAL,
|
|
32442
|
+
comment TEXT,
|
|
32443
|
+
venue_id INTEGER,
|
|
32444
|
+
venue_name TEXT,
|
|
32445
|
+
created_at TEXT
|
|
32446
|
+
)`,
|
|
32447
|
+
`CREATE INDEX IF NOT EXISTS idx_checkins_user_bid ON checkins(username, bid)`,
|
|
32448
|
+
`CREATE INDEX IF NOT EXISTS idx_checkins_user_brewery ON checkins(username, brewery_id)`,
|
|
32449
|
+
`CREATE TABLE IF NOT EXISTS sync_state (
|
|
32450
|
+
username TEXT PRIMARY KEY,
|
|
32451
|
+
oldest_max_id INTEGER,
|
|
32452
|
+
newest_checkin_id INTEGER,
|
|
32453
|
+
catchup_max_id INTEGER,
|
|
32454
|
+
last_synced_at TEXT,
|
|
32455
|
+
backfill_complete INTEGER NOT NULL DEFAULT 0,
|
|
32456
|
+
total_checkins INTEGER
|
|
32457
|
+
)`
|
|
32458
|
+
];
|
|
32459
|
+
var MIGRATIONS = ["ALTER TABLE sync_state ADD COLUMN catchup_max_id INTEGER"];
|
|
32460
|
+
var CheckinStoreCore = class {
|
|
32461
|
+
constructor(db) {
|
|
32462
|
+
this.db = db;
|
|
32463
|
+
for (const stmt of SCHEMA_STATEMENTS) this.db.execScript(stmt);
|
|
32464
|
+
for (const mig of MIGRATIONS) {
|
|
32465
|
+
try {
|
|
32466
|
+
this.db.execScript(mig);
|
|
32467
|
+
} catch {
|
|
32468
|
+
}
|
|
32469
|
+
}
|
|
32470
|
+
}
|
|
32471
|
+
db;
|
|
32472
|
+
/**
|
|
32473
|
+
* Upsert a batch of rows (dedupe on checkin_id) inside one transaction and
|
|
32474
|
+
* return the NET number of newly-inserted rows (updates to existing rows count
|
|
32475
|
+
* as 0), which is what "rows added" reports.
|
|
32476
|
+
*/
|
|
32477
|
+
upsertCheckins(username, rows) {
|
|
32478
|
+
if (rows.length === 0) return 0;
|
|
32479
|
+
const key = username.toLowerCase();
|
|
32480
|
+
const before = this.cachedCount(username);
|
|
32481
|
+
const sql = `
|
|
32482
|
+
INSERT INTO checkins
|
|
32483
|
+
(checkin_id, username, bid, beer_name, brewery_id, brewery_name, beer_style, abv, rating, comment, venue_id, venue_name, created_at)
|
|
32484
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
32485
|
+
ON CONFLICT(checkin_id) DO UPDATE SET
|
|
32486
|
+
username=excluded.username, bid=excluded.bid, beer_name=excluded.beer_name,
|
|
32487
|
+
brewery_id=excluded.brewery_id, brewery_name=excluded.brewery_name, beer_style=excluded.beer_style,
|
|
32488
|
+
abv=excluded.abv, rating=excluded.rating, comment=excluded.comment,
|
|
32489
|
+
venue_id=excluded.venue_id, venue_name=excluded.venue_name, created_at=excluded.created_at`;
|
|
32490
|
+
this.db.transaction(() => {
|
|
32491
|
+
for (const r of rows) {
|
|
32492
|
+
this.db.run(sql, [
|
|
32493
|
+
r.checkin_id,
|
|
32494
|
+
key,
|
|
32495
|
+
r.bid,
|
|
32496
|
+
r.beer_name,
|
|
32497
|
+
r.brewery_id,
|
|
32498
|
+
r.brewery_name,
|
|
32499
|
+
r.beer_style,
|
|
32500
|
+
r.abv,
|
|
32501
|
+
r.rating,
|
|
32502
|
+
r.comment,
|
|
32503
|
+
r.venue_id,
|
|
32504
|
+
r.venue_name,
|
|
32505
|
+
r.created_at
|
|
32506
|
+
]);
|
|
32507
|
+
}
|
|
32508
|
+
});
|
|
32509
|
+
return this.cachedCount(username) - before;
|
|
32510
|
+
}
|
|
32511
|
+
/** Total cached check-ins for a user (= distinct check-ins, since PK is checkin_id). */
|
|
32512
|
+
cachedCount(username) {
|
|
32513
|
+
const row = this.db.get("SELECT COUNT(*) AS n FROM checkins WHERE username = ?", [username.toLowerCase()]);
|
|
32514
|
+
return Number(row?.n ?? 0);
|
|
32515
|
+
}
|
|
32516
|
+
/** Distinct beers (unique bids) cached for a user. */
|
|
32517
|
+
distinctBeerCount(username) {
|
|
32518
|
+
const row = this.db.get(
|
|
32519
|
+
"SELECT COUNT(DISTINCT bid) AS n FROM checkins WHERE username = ? AND bid IS NOT NULL",
|
|
32520
|
+
[username.toLowerCase()]
|
|
32521
|
+
);
|
|
32522
|
+
return Number(row?.n ?? 0);
|
|
32523
|
+
}
|
|
32524
|
+
/** Highest cached checkin_id for a user (the true newest once contiguous), or null. */
|
|
32525
|
+
newestCachedId(username) {
|
|
32526
|
+
const row = this.db.get("SELECT MAX(checkin_id) AS m FROM checkins WHERE username = ?", [username.toLowerCase()]);
|
|
32527
|
+
return num(row?.m);
|
|
32528
|
+
}
|
|
32529
|
+
getState(username) {
|
|
32530
|
+
const row = this.db.get("SELECT * FROM sync_state WHERE username = ?", [username.toLowerCase()]);
|
|
32531
|
+
if (!row) return void 0;
|
|
32532
|
+
return {
|
|
32533
|
+
username: String(row.username),
|
|
32534
|
+
oldest_max_id: num(row.oldest_max_id),
|
|
32535
|
+
newest_checkin_id: num(row.newest_checkin_id),
|
|
32536
|
+
catchup_max_id: num(row.catchup_max_id),
|
|
32537
|
+
last_synced_at: str(row.last_synced_at),
|
|
32538
|
+
backfill_complete: Number(row.backfill_complete) === 1,
|
|
32539
|
+
total_checkins: num(row.total_checkins)
|
|
32540
|
+
};
|
|
32541
|
+
}
|
|
32542
|
+
/**
|
|
32543
|
+
* Insert-or-merge sync state. Only keys PRESENT in `patch` are written, so an
|
|
32544
|
+
* explicit `null` (e.g. clearing `oldest_max_id` when backfill completes)
|
|
32545
|
+
* overrides the stored value — a `??` merge could not do that.
|
|
32546
|
+
*/
|
|
32547
|
+
setState(username, patch) {
|
|
32548
|
+
const key = username.toLowerCase();
|
|
32549
|
+
const cur = this.getState(key);
|
|
32550
|
+
const pick2 = (k, fallback) => k in patch ? patch[k] : cur?.[k] ?? fallback;
|
|
32551
|
+
const next = {
|
|
32552
|
+
username: key,
|
|
32553
|
+
oldest_max_id: pick2("oldest_max_id", null),
|
|
32554
|
+
newest_checkin_id: pick2("newest_checkin_id", null),
|
|
32555
|
+
catchup_max_id: pick2("catchup_max_id", null),
|
|
32556
|
+
last_synced_at: pick2("last_synced_at", null),
|
|
32557
|
+
backfill_complete: pick2("backfill_complete", false),
|
|
32558
|
+
total_checkins: pick2("total_checkins", null)
|
|
32559
|
+
};
|
|
32560
|
+
this.db.run(
|
|
32561
|
+
`INSERT INTO sync_state (username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins)
|
|
32562
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
32563
|
+
ON CONFLICT(username) DO UPDATE SET
|
|
32564
|
+
oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
|
|
32565
|
+
catchup_max_id=excluded.catchup_max_id,
|
|
32566
|
+
last_synced_at=excluded.last_synced_at, backfill_complete=excluded.backfill_complete,
|
|
32567
|
+
total_checkins=excluded.total_checkins`,
|
|
32568
|
+
[
|
|
32569
|
+
key,
|
|
32570
|
+
next.oldest_max_id,
|
|
32571
|
+
next.newest_checkin_id,
|
|
32572
|
+
next.catchup_max_id,
|
|
32573
|
+
next.last_synced_at,
|
|
32574
|
+
next.backfill_complete ? 1 : 0,
|
|
32575
|
+
next.total_checkins
|
|
32576
|
+
]
|
|
32577
|
+
);
|
|
32578
|
+
}
|
|
32579
|
+
/** Exact bid match or case-insensitive substring match on beer name. */
|
|
32580
|
+
hasHad(username, opts) {
|
|
32581
|
+
const where = ["username = ?"];
|
|
32582
|
+
const params = [username.toLowerCase()];
|
|
32583
|
+
if (opts.bid !== void 0) {
|
|
32584
|
+
where.push("bid = ?");
|
|
32585
|
+
params.push(opts.bid);
|
|
32586
|
+
}
|
|
32587
|
+
if (opts.beerName !== void 0) {
|
|
32588
|
+
where.push("beer_name LIKE ? COLLATE NOCASE");
|
|
32589
|
+
params.push(`%${escapeLike(opts.beerName)}%`);
|
|
32590
|
+
}
|
|
32591
|
+
const clause = where.join(" AND ");
|
|
32592
|
+
const agg = this.db.get(`SELECT COUNT(*) AS n, MAX(rating) AS best FROM checkins WHERE ${clause}`, params);
|
|
32593
|
+
const matches = this.db.all(
|
|
32594
|
+
`SELECT checkin_id, bid, beer_name, rating, venue_name, created_at
|
|
32595
|
+
FROM checkins WHERE ${clause} ORDER BY checkin_id DESC LIMIT 25`,
|
|
32596
|
+
params
|
|
32597
|
+
);
|
|
32598
|
+
const count2 = Number(agg?.n ?? 0);
|
|
32599
|
+
return {
|
|
32600
|
+
had: count2 > 0,
|
|
32601
|
+
count: count2,
|
|
32602
|
+
last_date: matches[0]?.created_at ?? null,
|
|
32603
|
+
best_rating: num(agg?.best),
|
|
32604
|
+
matches
|
|
32605
|
+
};
|
|
32606
|
+
}
|
|
32607
|
+
query(username, filters) {
|
|
32608
|
+
const where = ["username = ?"];
|
|
32609
|
+
const params = [username.toLowerCase()];
|
|
32610
|
+
if (filters.brewery_id !== void 0) {
|
|
32611
|
+
where.push("brewery_id = ?");
|
|
32612
|
+
params.push(filters.brewery_id);
|
|
32613
|
+
}
|
|
32614
|
+
if (filters.brewery !== void 0) {
|
|
32615
|
+
where.push("brewery_name LIKE ? COLLATE NOCASE");
|
|
32616
|
+
params.push(`%${escapeLike(filters.brewery)}%`);
|
|
32617
|
+
}
|
|
32618
|
+
if (filters.style !== void 0) {
|
|
32619
|
+
where.push("beer_style LIKE ? COLLATE NOCASE");
|
|
32620
|
+
params.push(`%${escapeLike(filters.style)}%`);
|
|
32621
|
+
}
|
|
32622
|
+
if (filters.min_rating !== void 0) {
|
|
32623
|
+
where.push("rating >= ?");
|
|
32624
|
+
params.push(filters.min_rating);
|
|
32625
|
+
}
|
|
32626
|
+
if (filters.venue !== void 0) {
|
|
32627
|
+
where.push("venue_name LIKE ? COLLATE NOCASE");
|
|
32628
|
+
params.push(`%${escapeLike(filters.venue)}%`);
|
|
32629
|
+
}
|
|
32630
|
+
if (filters.venue_id !== void 0) {
|
|
32631
|
+
where.push("venue_id = ?");
|
|
32632
|
+
params.push(filters.venue_id);
|
|
32633
|
+
}
|
|
32634
|
+
if (filters.date_from !== void 0) {
|
|
32635
|
+
where.push("substr(created_at, 1, 10) >= ?");
|
|
32636
|
+
params.push(filters.date_from);
|
|
32637
|
+
}
|
|
32638
|
+
if (filters.date_to !== void 0) {
|
|
32639
|
+
where.push("substr(created_at, 1, 10) <= ?");
|
|
32640
|
+
params.push(filters.date_to);
|
|
32641
|
+
}
|
|
32642
|
+
const order = filters.sort === "oldest" ? "checkin_id ASC" : filters.sort === "highest_rated" ? "rating DESC, checkin_id DESC" : filters.sort === "lowest_rated" ? "rating ASC, checkin_id DESC" : "checkin_id DESC";
|
|
32643
|
+
const limit = Math.min(Math.max(filters.limit ?? 25, 1), 200);
|
|
32644
|
+
return this.db.all(
|
|
32645
|
+
`SELECT * FROM checkins WHERE ${where.join(" AND ")} ORDER BY ${order} LIMIT ?`,
|
|
32646
|
+
[...params, limit]
|
|
32647
|
+
);
|
|
32648
|
+
}
|
|
32649
|
+
};
|
|
32650
|
+
var LocalCacheStore = class {
|
|
32651
|
+
constructor(core) {
|
|
32652
|
+
this.core = core;
|
|
32653
|
+
}
|
|
32654
|
+
core;
|
|
32655
|
+
async upsertCheckins(username, rows) {
|
|
32656
|
+
return this.core.upsertCheckins(username, rows);
|
|
32657
|
+
}
|
|
32658
|
+
async cachedCount(username) {
|
|
32659
|
+
return this.core.cachedCount(username);
|
|
32660
|
+
}
|
|
32661
|
+
async distinctBeerCount(username) {
|
|
32662
|
+
return this.core.distinctBeerCount(username);
|
|
32663
|
+
}
|
|
32664
|
+
async newestCachedId(username) {
|
|
32665
|
+
return this.core.newestCachedId(username);
|
|
32666
|
+
}
|
|
32667
|
+
async getState(username) {
|
|
32668
|
+
return this.core.getState(username);
|
|
32669
|
+
}
|
|
32670
|
+
async setState(username, patch) {
|
|
32671
|
+
this.core.setState(username, patch);
|
|
32672
|
+
}
|
|
32673
|
+
async hasHad(username, opts) {
|
|
32674
|
+
return this.core.hasHad(username, opts);
|
|
32675
|
+
}
|
|
32676
|
+
async query(username, filters) {
|
|
32677
|
+
return this.core.query(username, filters);
|
|
32678
|
+
}
|
|
32679
|
+
};
|
|
32680
|
+
function escapeLike(s) {
|
|
32681
|
+
return s.replace(/[%_]/g, " ");
|
|
32682
|
+
}
|
|
32683
|
+
|
|
32684
|
+
// src/cache/sync.ts
|
|
32685
|
+
var PAGE_LIMIT = 50;
|
|
32686
|
+
async function fetchPage(client2, encodedUser, maxId) {
|
|
32687
|
+
const data = await client2.get(
|
|
32688
|
+
`/user/checkins/${encodedUser}`,
|
|
32689
|
+
{ limit: PAGE_LIMIT, max_id: maxId }
|
|
32690
|
+
);
|
|
32691
|
+
const box = data?.checkins ?? {};
|
|
32692
|
+
const items = Array.isArray(box.items) ? box.items : [];
|
|
32693
|
+
const raw = box.pagination?.max_id;
|
|
32694
|
+
const nextMaxId = typeof raw === "number" && raw > 0 ? raw : null;
|
|
32695
|
+
return { items, nextMaxId };
|
|
32696
|
+
}
|
|
32697
|
+
function rowsOf(username, items) {
|
|
32698
|
+
const rows = [];
|
|
32699
|
+
for (const it of items) {
|
|
32700
|
+
const r = mapCheckinRow(username, it);
|
|
32701
|
+
if (r) rows.push(r);
|
|
32702
|
+
}
|
|
32703
|
+
return rows;
|
|
32704
|
+
}
|
|
32705
|
+
async function fetchTotalCheckins(client2, encodedUser) {
|
|
32706
|
+
try {
|
|
32707
|
+
const info = await client2.get(`/user/info/${encodedUser}`);
|
|
32708
|
+
const t = info?.user?.stats?.total_checkins;
|
|
32709
|
+
return typeof t === "number" ? t : null;
|
|
32710
|
+
} catch {
|
|
32711
|
+
return null;
|
|
32712
|
+
}
|
|
32713
|
+
}
|
|
32714
|
+
async function syncCheckins(client2, cache, rawUsername, maxPages = 10) {
|
|
32715
|
+
const encodedUser = encodeURIComponent(rawUsername);
|
|
32716
|
+
const prior = await cache.getState(rawUsername);
|
|
32717
|
+
const priorNewest = prior?.newest_checkin_id ?? null;
|
|
32718
|
+
let pages = 0;
|
|
32719
|
+
let added = 0;
|
|
32720
|
+
let newest = priorNewest;
|
|
32721
|
+
let backfillComplete = prior?.backfill_complete ?? false;
|
|
32722
|
+
let oldestMaxId = prior?.oldest_max_id ?? null;
|
|
32723
|
+
let catchupMaxId = prior?.catchup_max_id ?? null;
|
|
32724
|
+
let catchupInProgress = catchupMaxId !== null;
|
|
32725
|
+
const now = () => (/* @__PURE__ */ new Date()).toISOString();
|
|
32726
|
+
const firstFetch = async (maxId) => {
|
|
32727
|
+
try {
|
|
32728
|
+
return await fetchPage(client2, encodedUser, maxId);
|
|
32729
|
+
} catch (e) {
|
|
32730
|
+
if (e instanceof RateLimitError) throw e;
|
|
32731
|
+
throw createHelpfulError(`Could not fetch check-ins for "${rawUsername}": ${messageOf(e)}`, {
|
|
32732
|
+
hint: "Untappd only returns another user's check-ins if their account is public or they're your friend. If the account is private, add them as a friend (untappd_add_friend) first, or sync only your own account."
|
|
32733
|
+
});
|
|
32734
|
+
}
|
|
32735
|
+
};
|
|
32736
|
+
if (priorNewest !== null) {
|
|
32737
|
+
let maxId = catchupMaxId ?? void 0;
|
|
32738
|
+
let caughtUp = false;
|
|
32739
|
+
for (; pages < maxPages; ) {
|
|
32740
|
+
const page = pages === 0 ? await firstFetch(maxId) : await fetchPage(client2, encodedUser, maxId);
|
|
32741
|
+
pages++;
|
|
32742
|
+
if (page.items.length === 0) {
|
|
32743
|
+
caughtUp = true;
|
|
32744
|
+
break;
|
|
32745
|
+
}
|
|
32746
|
+
const rows = rowsOf(rawUsername, page.items);
|
|
32747
|
+
added += await cache.upsertCheckins(rawUsername, rows);
|
|
32748
|
+
const oldestOnPage = rows[rows.length - 1].checkin_id;
|
|
32749
|
+
if (oldestOnPage <= priorNewest) {
|
|
32750
|
+
caughtUp = true;
|
|
32751
|
+
break;
|
|
32752
|
+
}
|
|
32753
|
+
if (page.nextMaxId === null) {
|
|
32754
|
+
caughtUp = true;
|
|
32755
|
+
backfillComplete = true;
|
|
32756
|
+
oldestMaxId = null;
|
|
32757
|
+
break;
|
|
32758
|
+
}
|
|
32759
|
+
catchupMaxId = page.nextMaxId;
|
|
32760
|
+
await cache.setState(rawUsername, { catchup_max_id: catchupMaxId, last_synced_at: now() });
|
|
32761
|
+
maxId = page.nextMaxId;
|
|
32762
|
+
}
|
|
32763
|
+
if (caughtUp) {
|
|
32764
|
+
newest = await cache.newestCachedId(rawUsername);
|
|
32765
|
+
catchupMaxId = null;
|
|
32766
|
+
catchupInProgress = false;
|
|
32767
|
+
await cache.setState(rawUsername, {
|
|
32768
|
+
newest_checkin_id: newest,
|
|
32769
|
+
catchup_max_id: null,
|
|
32770
|
+
backfill_complete: backfillComplete,
|
|
32771
|
+
oldest_max_id: oldestMaxId,
|
|
32772
|
+
last_synced_at: now()
|
|
32773
|
+
});
|
|
32774
|
+
} else {
|
|
32775
|
+
catchupInProgress = true;
|
|
32776
|
+
}
|
|
32777
|
+
}
|
|
32778
|
+
if (!backfillComplete && !catchupInProgress) {
|
|
32779
|
+
let maxId = oldestMaxId ?? void 0;
|
|
32780
|
+
for (; pages < maxPages; ) {
|
|
32781
|
+
const first = pages === 0;
|
|
32782
|
+
const page = first ? await firstFetch(maxId) : await fetchPage(client2, encodedUser, maxId);
|
|
32783
|
+
pages++;
|
|
32784
|
+
if (page.items.length === 0) {
|
|
32785
|
+
backfillComplete = true;
|
|
32786
|
+
break;
|
|
32787
|
+
}
|
|
32788
|
+
const rows = rowsOf(rawUsername, page.items);
|
|
32789
|
+
added += await cache.upsertCheckins(rawUsername, rows);
|
|
32790
|
+
if (newest === null) newest = rows[0].checkin_id;
|
|
32791
|
+
oldestMaxId = page.nextMaxId;
|
|
32792
|
+
if (page.nextMaxId === null) backfillComplete = true;
|
|
32793
|
+
await cache.setState(rawUsername, {
|
|
32794
|
+
newest_checkin_id: newest,
|
|
32795
|
+
oldest_max_id: oldestMaxId,
|
|
32796
|
+
backfill_complete: backfillComplete,
|
|
32797
|
+
last_synced_at: now()
|
|
32798
|
+
});
|
|
32799
|
+
if (backfillComplete) break;
|
|
32800
|
+
maxId = page.nextMaxId ?? void 0;
|
|
32801
|
+
}
|
|
32802
|
+
}
|
|
32803
|
+
const total = await fetchTotalCheckins(client2, encodedUser);
|
|
32804
|
+
const cached2 = await cache.cachedCount(rawUsername);
|
|
32805
|
+
await cache.setState(rawUsername, {
|
|
32806
|
+
newest_checkin_id: newest,
|
|
32807
|
+
oldest_max_id: oldestMaxId,
|
|
32808
|
+
catchup_max_id: catchupMaxId,
|
|
32809
|
+
backfill_complete: backfillComplete,
|
|
32810
|
+
total_checkins: total,
|
|
32811
|
+
last_synced_at: now()
|
|
32812
|
+
});
|
|
32813
|
+
const backfillPercent = total && total > 0 ? Math.min(100, Math.round(cached2 / total * 100)) : backfillComplete && !catchupInProgress ? 100 : null;
|
|
32814
|
+
const anotherRunNeeded = !backfillComplete || catchupInProgress;
|
|
32815
|
+
const note = catchupInProgress ? "More new check-ins remain than fit in this run \u2014 run untappd_sync_checkins again to finish catching up the newest check-ins." : backfillComplete ? "Full history is cached. Re-run occasionally to pick up new check-ins." : `Backfill incomplete \u2014 run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
|
|
32816
|
+
return {
|
|
32817
|
+
username: rawUsername,
|
|
32818
|
+
rows_added: added,
|
|
32819
|
+
pages_fetched: pages,
|
|
32820
|
+
cached_checkins: cached2,
|
|
32821
|
+
distinct_beers: await cache.distinctBeerCount(rawUsername),
|
|
32822
|
+
total_checkins: total,
|
|
32823
|
+
backfill_percent: backfillPercent,
|
|
32824
|
+
backfill_complete: backfillComplete,
|
|
32825
|
+
catchup_in_progress: catchupInProgress,
|
|
32826
|
+
another_run_needed: anotherRunNeeded,
|
|
32827
|
+
last_synced_at: now(),
|
|
32828
|
+
note
|
|
32829
|
+
};
|
|
32830
|
+
}
|
|
32831
|
+
|
|
32832
|
+
// src/tools/cache.ts
|
|
32833
|
+
var UsernameArg2 = external_exports.string().min(1).optional().describe("Untappd username. Omit to use your own configured account (UNTAPPD_USERNAME).");
|
|
32834
|
+
function resolveUser2(username, loginName) {
|
|
32835
|
+
const u = username ?? loginName;
|
|
32836
|
+
if (!u) {
|
|
32837
|
+
throw createHelpfulError("No username given and no configured account to fall back to.", {
|
|
32838
|
+
hint: "Pass `username`, or set UNTAPPD_USERNAME so cache tools default to your own account."
|
|
32839
|
+
});
|
|
32840
|
+
}
|
|
32841
|
+
return u;
|
|
32842
|
+
}
|
|
32843
|
+
async function freshness(cache, username) {
|
|
32844
|
+
const state = await cache.getState(username);
|
|
32845
|
+
const cached2 = await cache.cachedCount(username);
|
|
32846
|
+
const backfillComplete = state?.backfill_complete ?? false;
|
|
32847
|
+
const percent = state?.total_checkins && state.total_checkins > 0 ? Math.min(100, Math.round(cached2 / state.total_checkins * 100)) : backfillComplete ? 100 : null;
|
|
32848
|
+
const catchupInProgress = (state?.catchup_max_id ?? null) !== null;
|
|
32849
|
+
let caveat;
|
|
32850
|
+
if (!state || state.last_synced_at === null) {
|
|
32851
|
+
caveat = "This user has never been synced \u2014 the cache is EMPTY. Run untappd_sync_checkins first; results here are not authoritative.";
|
|
32852
|
+
} else if (catchupInProgress) {
|
|
32853
|
+
caveat = "A batch of the newest check-ins is still being caught up (last sync ran out of pages). Recent check-ins may be missing \u2014 run untappd_sync_checkins again to finish.";
|
|
32854
|
+
} else if (!backfillComplete) {
|
|
32855
|
+
caveat = `Backfill is incomplete${percent !== null ? ` (~${percent}% of history cached)` : ""}. A "not found" result may be a false negative for older check-ins \u2014 run untappd_sync_checkins again to keep backfilling.`;
|
|
32856
|
+
}
|
|
32857
|
+
return {
|
|
32858
|
+
last_synced_at: state?.last_synced_at ?? null,
|
|
32859
|
+
backfill_complete: backfillComplete,
|
|
32860
|
+
catchup_in_progress: catchupInProgress,
|
|
32861
|
+
backfill_percent: percent,
|
|
32862
|
+
cached_checkins: cached2,
|
|
32863
|
+
distinct_beers: await cache.distinctBeerCount(username),
|
|
32864
|
+
...caveat ? { caveat } : {}
|
|
32865
|
+
};
|
|
32866
|
+
}
|
|
32867
|
+
function registerCacheTools(server, client2, cacheProvider) {
|
|
32868
|
+
server.registerTool(
|
|
32869
|
+
"untappd_sync_checkins",
|
|
32870
|
+
{
|
|
32871
|
+
title: "Sync Untappd check-ins into the cache",
|
|
32872
|
+
description: "Fetch a user's check-ins into a persistent cache so history can be queried without paging the API. Incremental (stops once it reaches already-cached check-ins) and resumable: if the backfill is not yet complete it pages backwards up to max_pages per call and persists progress after every page, so run it repeatedly until another_run_needed is false. Syncing another user requires their account be public or a friend. Omit username for your own account.",
|
|
32873
|
+
annotations: toolAnnotations({ title: "Sync Untappd check-ins into the cache", readOnly: false, idempotent: false, openWorld: true }),
|
|
32874
|
+
inputSchema: {
|
|
32875
|
+
username: UsernameArg2,
|
|
32876
|
+
max_pages: external_exports.number().int().min(1).max(50).optional().describe("Total pages (50 check-ins each) to fetch this run, incremental + backfill (default 10). Keep modest to respect the ~100 calls/hour rate limit.")
|
|
32877
|
+
}
|
|
32878
|
+
},
|
|
32879
|
+
async ({ username, max_pages }) => {
|
|
32880
|
+
const user = resolveUser2(username, client2.loginName);
|
|
32881
|
+
const summary = await syncCheckins(client2, cacheProvider(), user, max_pages ?? 10);
|
|
32882
|
+
return textResult(summary);
|
|
32883
|
+
}
|
|
32884
|
+
);
|
|
32885
|
+
server.registerTool(
|
|
32886
|
+
"untappd_cache_has_had",
|
|
32887
|
+
{
|
|
32888
|
+
title: "Check if a user has had a beer (from the cache)",
|
|
32889
|
+
description: 'Answer "has this user ever checked in this beer?" from the cache only \u2014 NO API call. Match by exact bid, or by a case-insensitive substring of the beer name. Returns whether they had it, how many times, the best rating, the last date, and the matching check-ins. Always reports cache freshness so you can caveat incomplete data. Requires bid or beer_name. Run untappd_sync_checkins first.',
|
|
32890
|
+
annotations: toolAnnotations({ title: "Check if a user has had a beer (from the cache)", readOnly: true, idempotent: true, openWorld: false }),
|
|
32891
|
+
inputSchema: {
|
|
32892
|
+
username: UsernameArg2,
|
|
32893
|
+
bid: external_exports.number().int().positive().optional().describe("Exact Untappd beer id to look for"),
|
|
32894
|
+
beer_name: external_exports.string().min(1).optional().describe("Case-insensitive substring match on the beer name")
|
|
32895
|
+
}
|
|
32896
|
+
},
|
|
32897
|
+
async ({ username, bid, beer_name }) => {
|
|
32898
|
+
if (bid === void 0 && beer_name === void 0) {
|
|
32899
|
+
throw createHelpfulError("Provide either bid or beer_name.", { hint: "bid is exact; beer_name is a case-insensitive substring match." });
|
|
32900
|
+
}
|
|
32901
|
+
const user = resolveUser2(username, client2.loginName);
|
|
32902
|
+
const cache = cacheProvider();
|
|
32903
|
+
const result = await cache.hasHad(user, { bid, beerName: beer_name });
|
|
32904
|
+
return textResult({ username: user, query: { bid, beer_name }, ...result, freshness: await freshness(cache, user) });
|
|
32905
|
+
}
|
|
32906
|
+
);
|
|
32907
|
+
server.registerTool(
|
|
32908
|
+
"untappd_cache_has_had_many",
|
|
32909
|
+
{
|
|
32910
|
+
title: "Batch-check many beers against the cache",
|
|
32911
|
+
description: "Cross-check a list of beer ids against a user's cached history in ONE call \u2014 NO API call. Returns had/not-had per bid (with count and last date when had). Ideal for checking a whole venue menu at once. Run untappd_sync_checkins first; the freshness block flags if the cache is incomplete.",
|
|
32912
|
+
annotations: toolAnnotations({ title: "Batch-check many beers against the cache", readOnly: true, idempotent: true, openWorld: false }),
|
|
32913
|
+
inputSchema: {
|
|
32914
|
+
username: UsernameArg2,
|
|
32915
|
+
bids: external_exports.array(external_exports.number().int().positive()).min(1).max(500).describe("Beer ids to check (1\u2013500)")
|
|
32916
|
+
}
|
|
32917
|
+
},
|
|
32918
|
+
async ({ username, bids }) => {
|
|
32919
|
+
const user = resolveUser2(username, client2.loginName);
|
|
32920
|
+
const cache = cacheProvider();
|
|
32921
|
+
const seen = /* @__PURE__ */ new Set();
|
|
32922
|
+
const results = [];
|
|
32923
|
+
let hadCount = 0;
|
|
32924
|
+
for (const bid of bids) {
|
|
32925
|
+
if (seen.has(bid)) continue;
|
|
32926
|
+
seen.add(bid);
|
|
32927
|
+
const r = await cache.hasHad(user, { bid });
|
|
32928
|
+
if (r.had) hadCount++;
|
|
32929
|
+
results.push({ bid, had: r.had, count: r.count, last_date: r.last_date, best_rating: r.best_rating });
|
|
32930
|
+
}
|
|
32931
|
+
return textResult({
|
|
32932
|
+
username: user,
|
|
32933
|
+
checked: seen.size,
|
|
32934
|
+
had: hadCount,
|
|
32935
|
+
not_had: seen.size - hadCount,
|
|
32936
|
+
results,
|
|
32937
|
+
freshness: await freshness(cache, user)
|
|
32938
|
+
});
|
|
32939
|
+
}
|
|
32940
|
+
);
|
|
32941
|
+
server.registerTool(
|
|
32942
|
+
"untappd_cache_query",
|
|
32943
|
+
{
|
|
32944
|
+
title: "Query cached check-ins with filters",
|
|
32945
|
+
description: "Query a user's cached check-ins by brewery, style, minimum rating, venue, and/or date range, with sorting and a limit \u2014 from the cache only, NO API call. Returns the matching check-ins plus cache freshness. Run untappd_sync_checkins first.",
|
|
32946
|
+
annotations: toolAnnotations({ title: "Query cached check-ins with filters", readOnly: true, idempotent: true, openWorld: false }),
|
|
32947
|
+
inputSchema: {
|
|
32948
|
+
username: UsernameArg2,
|
|
32949
|
+
brewery_id: external_exports.number().int().positive().optional().describe("Exact brewery id"),
|
|
32950
|
+
brewery: external_exports.string().min(1).optional().describe("Case-insensitive substring match on brewery name"),
|
|
32951
|
+
style: external_exports.string().min(1).optional().describe('Case-insensitive substring match on beer style (e.g. "IPA")'),
|
|
32952
|
+
min_rating: external_exports.number().min(0).max(5).optional().describe("Only check-ins you rated at least this (0\u20135)"),
|
|
32953
|
+
venue: external_exports.string().min(1).optional().describe("Case-insensitive substring match on venue name"),
|
|
32954
|
+
venue_id: external_exports.number().int().positive().optional().describe("Exact venue id"),
|
|
32955
|
+
date_from: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Only check-ins on/after this date (YYYY-MM-DD, UTC)"),
|
|
32956
|
+
date_to: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Only check-ins on/before this date (YYYY-MM-DD, UTC)"),
|
|
32957
|
+
sort: external_exports.enum(["recent", "oldest", "highest_rated", "lowest_rated"]).optional().describe("Sort order (default recent first)"),
|
|
32958
|
+
limit: external_exports.number().int().min(1).max(200).optional().describe("Max rows (1\u2013200, default 25)")
|
|
32959
|
+
}
|
|
32960
|
+
},
|
|
32961
|
+
async ({ username, ...filters }) => {
|
|
32962
|
+
const user = resolveUser2(username, client2.loginName);
|
|
32963
|
+
const cache = cacheProvider();
|
|
32964
|
+
const rows = await cache.query(user, filters);
|
|
32965
|
+
return textResult({ username: user, count: rows.length, filters, results: rows, freshness: await freshness(cache, user) });
|
|
32966
|
+
}
|
|
32967
|
+
);
|
|
32968
|
+
}
|
|
32969
|
+
|
|
32970
|
+
// src/cache/db.ts
|
|
32971
|
+
import { DatabaseSync } from "node:sqlite";
|
|
32972
|
+
import { mkdirSync } from "node:fs";
|
|
32973
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
32974
|
+
import { homedir } from "node:os";
|
|
32975
|
+
var NodeSqlDriver = class {
|
|
32976
|
+
constructor(db) {
|
|
32977
|
+
this.db = db;
|
|
32978
|
+
}
|
|
32979
|
+
db;
|
|
32980
|
+
execScript(sql) {
|
|
32981
|
+
this.db.exec(sql);
|
|
32982
|
+
}
|
|
32983
|
+
run(sql, params) {
|
|
32984
|
+
this.db.prepare(sql).run(...params);
|
|
32985
|
+
}
|
|
32986
|
+
get(sql, params) {
|
|
32987
|
+
return this.db.prepare(sql).get(...params);
|
|
32988
|
+
}
|
|
32989
|
+
all(sql, params) {
|
|
32990
|
+
return this.db.prepare(sql).all(...params);
|
|
32991
|
+
}
|
|
32992
|
+
transaction(fn) {
|
|
32993
|
+
this.db.exec("BEGIN");
|
|
32994
|
+
try {
|
|
32995
|
+
fn();
|
|
32996
|
+
this.db.exec("COMMIT");
|
|
32997
|
+
} catch (e) {
|
|
32998
|
+
this.db.exec("ROLLBACK");
|
|
32999
|
+
throw e;
|
|
33000
|
+
}
|
|
33001
|
+
}
|
|
33002
|
+
};
|
|
33003
|
+
var CheckinCache = class _CheckinCache extends LocalCacheStore {
|
|
33004
|
+
constructor(db) {
|
|
33005
|
+
super(new CheckinStoreCore(new NodeSqlDriver(db)));
|
|
33006
|
+
this.db = db;
|
|
33007
|
+
}
|
|
33008
|
+
db;
|
|
33009
|
+
/** Open (creating parent dirs) a file-backed cache, or `:memory:` for tests. */
|
|
33010
|
+
static open(path) {
|
|
33011
|
+
if (path !== ":memory:") mkdirSync(dirname2(path), { recursive: true });
|
|
33012
|
+
return new _CheckinCache(new DatabaseSync(path));
|
|
33013
|
+
}
|
|
33014
|
+
close() {
|
|
33015
|
+
this.db.close();
|
|
33016
|
+
}
|
|
33017
|
+
};
|
|
33018
|
+
function defaultCachePath() {
|
|
33019
|
+
return readEnvVar("UNTAPPD_CACHE_DB") ?? join2(homedir(), ".untappd-mcp", "checkins.db");
|
|
33020
|
+
}
|
|
33021
|
+
|
|
32366
33022
|
// src/index.ts
|
|
32367
33023
|
var client = new UntappdClient();
|
|
33024
|
+
var nodeCache;
|
|
33025
|
+
var nodeCacheProvider = () => nodeCache ??= CheckinCache.open(defaultCachePath());
|
|
32368
33026
|
await runMcp({
|
|
32369
33027
|
name: "untappd-mcp",
|
|
32370
33028
|
version: VERSION,
|
|
@@ -32380,6 +33038,12 @@ await runMcp({
|
|
|
32380
33038
|
(s) => registerFriendActionTools(s, client),
|
|
32381
33039
|
(s) => registerWishlistTools(s, client),
|
|
32382
33040
|
(s) => registerCheckinTools(s, client),
|
|
32383
|
-
(s) => registerUtilityTools(s, client)
|
|
33041
|
+
(s) => registerUtilityTools(s, client),
|
|
33042
|
+
// The stdio server backs the cache with a local `node:sqlite` file; the
|
|
33043
|
+
// remote Cloudflare connector (src/worker.ts) backs the same cache tools
|
|
33044
|
+
// with a per-user Durable Object instead.
|
|
33045
|
+
(s) => registerCacheTools(s, client, nodeCacheProvider),
|
|
33046
|
+
// Keep last: logs the full registered toolset (count + names) at startup.
|
|
33047
|
+
(s) => logRegisteredTools(s, "stdio")
|
|
32384
33048
|
]
|
|
32385
33049
|
});
|