Merge branch 'fix/telegram-empty-response-notification'
This commit is contained in:
commit
60453c912e
@ -249,6 +249,9 @@
|
||||
"@sinclair/typebox": "0.34.47",
|
||||
"hono": "4.11.4",
|
||||
"tar": "7.5.4"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"@mariozechner/pi-ai@0.49.3": "patches/@mariozechner__pi-ai@0.49.3.patch"
|
||||
}
|
||||
},
|
||||
"vitest": {
|
||||
|
||||
58
patches/@mariozechner__pi-ai@0.49.3.patch
Normal file
58
patches/@mariozechner__pi-ai@0.49.3.patch
Normal file
@ -0,0 +1,58 @@
|
||||
diff --git a/dist/providers/openai-completions.js b/dist/providers/openai-completions.js
|
||||
index c4b291d86ce40b2df319c439263d16d67592d22e..449da02d0d2c85309979e392909df5d3fb2ad083 100644
|
||||
--- a/dist/providers/openai-completions.js
|
||||
+++ b/dist/providers/openai-completions.js
|
||||
@@ -228,7 +228,13 @@ export const streamOpenAICompletions = (model, context, options) => {
|
||||
}
|
||||
const reasoningDetails = choice.delta.reasoning_details;
|
||||
if (reasoningDetails && Array.isArray(reasoningDetails)) {
|
||||
+ // Store ALL reasoning_details for OpenRouter compatibility (not just encrypted)
|
||||
+ if (!output._rawReasoningDetails) {
|
||||
+ output._rawReasoningDetails = [];
|
||||
+ }
|
||||
for (const detail of reasoningDetails) {
|
||||
+ output._rawReasoningDetails.push(detail);
|
||||
+ // Also handle encrypted type tied to tool calls (existing behavior)
|
||||
if (detail.type === "reasoning.encrypted" && detail.id && detail.data) {
|
||||
const matchingToolCall = output.content.find((b) => b.type === "toolCall" && b.id === detail.id);
|
||||
if (matchingToolCall) {
|
||||
@@ -500,19 +506,27 @@ function convertMessages(model, context, compat) {
|
||||
arguments: JSON.stringify(tc.arguments),
|
||||
},
|
||||
}));
|
||||
- const reasoningDetails = toolCalls
|
||||
- .filter((tc) => tc.thoughtSignature)
|
||||
- .map((tc) => {
|
||||
- try {
|
||||
- return JSON.parse(tc.thoughtSignature);
|
||||
- }
|
||||
- catch {
|
||||
- return null;
|
||||
+ // First check for raw reasoning_details stored on the message (OpenRouter models like Kimi)
|
||||
+ const rawDetails = msg._rawReasoningDetails;
|
||||
+ if (rawDetails && Array.isArray(rawDetails) && rawDetails.length > 0) {
|
||||
+ assistantMsg.reasoning_details = rawDetails;
|
||||
+ }
|
||||
+ else {
|
||||
+ // Fall back to extracting from tool call thoughtSignatures (encrypted type)
|
||||
+ const reasoningDetails = toolCalls
|
||||
+ .filter((tc) => tc.thoughtSignature)
|
||||
+ .map((tc) => {
|
||||
+ try {
|
||||
+ return JSON.parse(tc.thoughtSignature);
|
||||
+ }
|
||||
+ catch {
|
||||
+ return null;
|
||||
+ }
|
||||
+ })
|
||||
+ .filter(Boolean);
|
||||
+ if (reasoningDetails.length > 0) {
|
||||
+ assistantMsg.reasoning_details = reasoningDetails;
|
||||
}
|
||||
- })
|
||||
- .filter(Boolean);
|
||||
- if (reasoningDetails.length > 0) {
|
||||
- assistantMsg.reasoning_details = reasoningDetails;
|
||||
}
|
||||
}
|
||||
// Skip assistant messages that have no content and no tool calls.
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@ -9,6 +9,11 @@ overrides:
|
||||
hono: 4.11.4
|
||||
tar: 7.5.4
|
||||
|
||||
patchedDependencies:
|
||||
'@mariozechner/pi-ai@0.49.3':
|
||||
hash: 8fada8af40beb6c2780b3adec72505288272a5df0ec6227b710caef6afb1dc32
|
||||
path: patches/@mariozechner__pi-ai@0.49.3.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@ -45,7 +50,7 @@ importers:
|
||||
version: 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: 0.49.3
|
||||
version: 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
version: 0.49.3(patch_hash=8fada8af40beb6c2780b3adec72505288272a5df0ec6227b710caef6afb1dc32)(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: 0.49.3
|
||||
version: 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
@ -6995,7 +7000,7 @@ snapshots:
|
||||
|
||||
'@mariozechner/pi-agent-core@0.49.3(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(patch_hash=8fada8af40beb6c2780b3adec72505288272a5df0ec6227b710caef6afb1dc32)(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.49.3
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
@ -7006,7 +7011,7 @@ snapshots:
|
||||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.49.3(ws@8.19.0)(zod@4.3.6)':
|
||||
'@mariozechner/pi-ai@0.49.3(patch_hash=8fada8af40beb6c2780b3adec72505288272a5df0ec6227b710caef6afb1dc32)(ws@8.19.0)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.972.0
|
||||
@ -7033,7 +7038,7 @@ snapshots:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(patch_hash=8fada8af40beb6c2780b3adec72505288272a5df0ec6227b710caef6afb1dc32)(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.49.3
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
@ -9110,7 +9115,7 @@ snapshots:
|
||||
'@line/bot-sdk': 10.6.0
|
||||
'@lydell/node-pty': 1.2.0-beta.3
|
||||
'@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.49.3(patch_hash=8fada8af40beb6c2780b3adec72505288272a5df0ec6227b710caef6afb1dc32)(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.49.3
|
||||
'@mozilla/readability': 0.6.0
|
||||
|
||||
136
skills/ga4/SKILL.md
Normal file
136
skills/ga4/SKILL.md
Normal file
@ -0,0 +1,136 @@
|
||||
---
|
||||
name: ga4
|
||||
description: GA4 Analytics CLI for users, events, funnels, and transcription metrics.
|
||||
homepage: https://analytics.google.com
|
||||
metadata: {"clawdbot":{"emoji":"📊","requires":{"bins":["bun"],"env":["GOOGLE_APPLICATION_CREDENTIALS"]},"primaryEnv":"GOOGLE_APPLICATION_CREDENTIALS"}}
|
||||
---
|
||||
|
||||
# GA4 Analytics CLI
|
||||
|
||||
Query Google Analytics 4 data - users, events, funnels, transcription metrics.
|
||||
|
||||
## Setup
|
||||
|
||||
Set environment variables:
|
||||
```bash
|
||||
export GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account.json
|
||||
export GA4_PROPERTY_ID=510572418 # Optional, defaults to Speakmac
|
||||
```
|
||||
|
||||
Or configure in `~/.clawdbot/clawdbot.json`:
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"ga4": {
|
||||
"env": {
|
||||
"GOOGLE_APPLICATION_CREDENTIALS": "/path/to/service-account.json",
|
||||
"GA4_PROPERTY_ID": "510572418"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Overview
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts overview # 30-day overview
|
||||
bun {baseDir}/scripts/cli.ts overview --period 7d # Last 7 days
|
||||
```
|
||||
|
||||
### Real-time
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts realtime # Active users now
|
||||
```
|
||||
|
||||
### Events
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts events # Top 50 events
|
||||
bun {baseDir}/scripts/cli.ts events --limit 20 # Limit results
|
||||
bun {baseDir}/scripts/cli.ts events --period 7d
|
||||
```
|
||||
|
||||
### Funnels
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts funnel onboarding # Onboarding completion
|
||||
bun {baseDir}/scripts/cli.ts funnel transcription # Transcription success
|
||||
bun {baseDir}/scripts/cli.ts funnel permissions # Permission grants
|
||||
```
|
||||
|
||||
### Errors
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts errors # All errors (7d)
|
||||
bun {baseDir}/scripts/cli.ts errors --type transcription
|
||||
bun {baseDir}/scripts/cli.ts errors --type license
|
||||
bun {baseDir}/scripts/cli.ts errors --period 14d
|
||||
```
|
||||
|
||||
### Transcription Metrics
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts transcriptions # By provider
|
||||
bun {baseDir}/scripts/cli.ts transcriptions --by day # Daily breakdown
|
||||
bun {baseDir}/scripts/cli.ts transcriptions --period 14d
|
||||
```
|
||||
|
||||
### Version Adoption
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts versions # App version usage
|
||||
bun {baseDir}/scripts/cli.ts versions --period 7d
|
||||
```
|
||||
|
||||
### Growth Metrics
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts growth # Growth trends
|
||||
bun {baseDir}/scripts/cli.ts growth --period 90d
|
||||
```
|
||||
|
||||
### Weekly (Date Range)
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts weekly --from 2025-11-01 --to 2026-01-20
|
||||
```
|
||||
|
||||
### Custom Query
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts query --dimensions week --metrics activeUsers,sessions --from 2025-11-01 --to 2026-01-20
|
||||
bun {baseDir}/scripts/cli.ts query --dimensions date --metrics eventCount --period 30d
|
||||
```
|
||||
|
||||
## Periods
|
||||
|
||||
- `today` - Current day
|
||||
- `yesterday` - Previous day
|
||||
- `7d` - Last 7 days
|
||||
- `14d` - Last 14 days
|
||||
- `28d` - Last 28 days
|
||||
- `30d` - Last 30 days (default)
|
||||
- `90d` - Last 90 days
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Daily check-in
|
||||
bun {baseDir}/scripts/cli.ts overview --period 7d
|
||||
bun {baseDir}/scripts/cli.ts realtime
|
||||
|
||||
# Feature usage
|
||||
bun {baseDir}/scripts/cli.ts events --limit 20
|
||||
|
||||
# Onboarding health
|
||||
bun {baseDir}/scripts/cli.ts funnel onboarding
|
||||
|
||||
# Transcription performance
|
||||
bun {baseDir}/scripts/cli.ts transcriptions --by provider
|
||||
bun {baseDir}/scripts/cli.ts errors --type transcription
|
||||
|
||||
# Version rollout
|
||||
bun {baseDir}/scripts/cli.ts versions --period 7d
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Service account needs "Viewer" role on GA4 property
|
||||
- Real-time data shows last 30 minutes
|
||||
- See `{baseDir}/docs/API_REFERENCE.md` for full API documentation
|
||||
671
skills/ga4/scripts/cli.ts
Normal file
671
skills/ga4/scripts/cli.ts
Normal file
@ -0,0 +1,671 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* GA4 Analytics CLI - Speakmac analytics at your fingertips
|
||||
* Usage: bun skills/ga4/cli.ts <command> [options]
|
||||
*/
|
||||
|
||||
import { BetaAnalyticsDataClient } from "@google-analytics/data";
|
||||
|
||||
const CREDENTIALS_PATH = process.env.GOOGLE_APPLICATION_CREDENTIALS;
|
||||
const PROPERTY_ID = process.env.GA4_PROPERTY_ID || "510572418";
|
||||
|
||||
if (!CREDENTIALS_PATH) {
|
||||
console.error("Error: GOOGLE_APPLICATION_CREDENTIALS environment variable required");
|
||||
console.error("Point it to your service account JSON file");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let client: BetaAnalyticsDataClient | null = null;
|
||||
|
||||
function getClient() {
|
||||
if (!client) {
|
||||
client = new BetaAnalyticsDataClient();
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
type DateRange = { startDate: string; endDate: string };
|
||||
|
||||
function getDateRange(period: string): DateRange {
|
||||
const ranges: Record<string, DateRange> = {
|
||||
today: { startDate: "today", endDate: "today" },
|
||||
yesterday: { startDate: "yesterday", endDate: "yesterday" },
|
||||
"7d": { startDate: "7daysAgo", endDate: "today" },
|
||||
"14d": { startDate: "14daysAgo", endDate: "today" },
|
||||
"28d": { startDate: "28daysAgo", endDate: "today" },
|
||||
"30d": { startDate: "30daysAgo", endDate: "today" },
|
||||
"90d": { startDate: "90daysAgo", endDate: "today" },
|
||||
};
|
||||
return ranges[period] || ranges["30d"];
|
||||
}
|
||||
|
||||
async function runReport(
|
||||
dimensions: string[],
|
||||
metrics: string[],
|
||||
dateRange: DateRange,
|
||||
filter?: any,
|
||||
limit = 100
|
||||
) {
|
||||
const c = getClient();
|
||||
const request: any = {
|
||||
property: `properties/${PROPERTY_ID}`,
|
||||
dateRanges: [dateRange],
|
||||
dimensions: dimensions.map((d) => ({ name: d })),
|
||||
metrics: metrics.map((m) => ({ name: m })),
|
||||
limit,
|
||||
};
|
||||
if (filter) request.dimensionFilter = filter;
|
||||
|
||||
const [response] = await c.runReport(request);
|
||||
|
||||
const results: Record<string, any>[] = [];
|
||||
if (response.rows) {
|
||||
for (const row of response.rows) {
|
||||
const result: Record<string, any> = {};
|
||||
dimensions.forEach((dim, i) => {
|
||||
result[dim] = row.dimensionValues?.[i]?.value || "(not set)";
|
||||
});
|
||||
metrics.forEach((metric, i) => {
|
||||
result[metric] = row.metricValues?.[i]?.value || "0";
|
||||
});
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
rowCount: response.rowCount || 0,
|
||||
results,
|
||||
totals:
|
||||
response.totals?.[0]?.metricValues?.map((m, i) => ({
|
||||
metric: metrics[i],
|
||||
value: m.value,
|
||||
})) || [],
|
||||
};
|
||||
}
|
||||
|
||||
// Commands
|
||||
async function overview(period: string) {
|
||||
const dateRange = getDateRange(period);
|
||||
const data = await runReport(
|
||||
["date"],
|
||||
[
|
||||
"activeUsers",
|
||||
"newUsers",
|
||||
"sessions",
|
||||
"eventCount",
|
||||
"engagementRate",
|
||||
"averageSessionDuration",
|
||||
],
|
||||
dateRange,
|
||||
null,
|
||||
50
|
||||
);
|
||||
|
||||
// Aggregate totals
|
||||
let users = 0,
|
||||
newU = 0,
|
||||
sessions = 0,
|
||||
events = 0;
|
||||
for (const row of data.results) {
|
||||
users += parseInt(row.activeUsers);
|
||||
newU += parseInt(row.newUsers);
|
||||
sessions += parseInt(row.sessions);
|
||||
events += parseInt(row.eventCount);
|
||||
}
|
||||
|
||||
console.log(`📊 GA4 Overview (${period})\n`);
|
||||
console.log(`Active Users: ${users}`);
|
||||
console.log(`New Users: ${newU}`);
|
||||
console.log(`Sessions: ${sessions}`);
|
||||
console.log(`Total Events: ${events}`);
|
||||
|
||||
if (data.totals.length > 0) {
|
||||
const engRate = data.totals.find((t) => t.metric === "engagementRate");
|
||||
const avgDur = data.totals.find(
|
||||
(t) => t.metric === "averageSessionDuration"
|
||||
);
|
||||
if (engRate)
|
||||
console.log(
|
||||
`Engagement: ${(parseFloat(engRate.value || "0") * 100).toFixed(1)}%`
|
||||
);
|
||||
if (avgDur)
|
||||
console.log(
|
||||
`Avg Session: ${Math.round(parseFloat(avgDur.value || "0"))}s`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function realtime() {
|
||||
const c = getClient();
|
||||
const [response] = await c.runRealtimeReport({
|
||||
property: `properties/${PROPERTY_ID}`,
|
||||
dimensions: [{ name: "unifiedScreenName" }],
|
||||
metrics: [{ name: "activeUsers" }, { name: "eventCount" }],
|
||||
});
|
||||
|
||||
const totalUsers =
|
||||
response.totals?.[0]?.metricValues?.[0]?.value || "0";
|
||||
|
||||
console.log(`⚡ Real-time (last 30 min)\n`);
|
||||
console.log(`Active Users: ${totalUsers}\n`);
|
||||
|
||||
if (response.rows && response.rows.length > 0) {
|
||||
console.log("By Screen:");
|
||||
for (const row of response.rows.slice(0, 10)) {
|
||||
const screen = row.dimensionValues?.[0]?.value || "(unknown)";
|
||||
const users = row.metricValues?.[0]?.value || "0";
|
||||
console.log(` ${users.padStart(3)} | ${screen}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function events(period: string, limit: number) {
|
||||
const dateRange = getDateRange(period);
|
||||
const data = await runReport(
|
||||
["eventName"],
|
||||
["eventCount", "eventCountPerUser"],
|
||||
dateRange,
|
||||
null,
|
||||
limit
|
||||
);
|
||||
|
||||
console.log(`📈 Events (${period})\n`);
|
||||
console.log("Event".padEnd(45) + "Count".padStart(10) + "Per User".padStart(10));
|
||||
console.log("-".repeat(65));
|
||||
|
||||
for (const row of data.results) {
|
||||
const name = row.eventName.substring(0, 44);
|
||||
const count = row.eventCount;
|
||||
const perUser = parseFloat(row.eventCountPerUser).toFixed(2);
|
||||
console.log(
|
||||
name.padEnd(45) + count.toString().padStart(10) + perUser.padStart(10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function funnel(funnelType: string, period: string) {
|
||||
const funnels: Record<string, string[]> = {
|
||||
onboarding: [
|
||||
"onboarding_started",
|
||||
"onboarding_step_completed",
|
||||
"permission_prompt_shown",
|
||||
"permission_result",
|
||||
"demo_transcription_started",
|
||||
"demo_transcription_completed",
|
||||
"onboarding_completed",
|
||||
],
|
||||
transcription: [
|
||||
"transcription_started",
|
||||
"transcription_completed",
|
||||
"transcription_failed",
|
||||
],
|
||||
permissions: ["permission_prompt_shown", "permission_result"],
|
||||
};
|
||||
|
||||
const events = funnels[funnelType];
|
||||
if (!events) {
|
||||
console.error(`Unknown funnel: ${funnelType}`);
|
||||
console.error(`Available: ${Object.keys(funnels).join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dateRange = getDateRange(period);
|
||||
console.log(`🔄 ${funnelType} Funnel (${period})\n`);
|
||||
|
||||
let firstCount = 0;
|
||||
for (const event of events) {
|
||||
const filter = {
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: { value: event, matchType: "EXACT" },
|
||||
},
|
||||
};
|
||||
const data = await runReport(
|
||||
["eventName"],
|
||||
["eventCount", "totalUsers"],
|
||||
dateRange,
|
||||
filter,
|
||||
1
|
||||
);
|
||||
|
||||
const count = parseInt(data.results[0]?.eventCount || "0");
|
||||
const users = parseInt(data.results[0]?.totalUsers || "0");
|
||||
|
||||
if (firstCount === 0) firstCount = count;
|
||||
const pct = firstCount > 0 ? ((count / firstCount) * 100).toFixed(0) : "0";
|
||||
|
||||
console.log(
|
||||
`${event.padEnd(35)} ${count.toString().padStart(6)} events (${pct}%)`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function errors(errorType: string, period: string) {
|
||||
const dateRange = getDateRange(period);
|
||||
|
||||
let filter: any;
|
||||
if (errorType === "transcription") {
|
||||
filter = {
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: { value: "transcription_failed", matchType: "EXACT" },
|
||||
},
|
||||
};
|
||||
} else if (errorType === "license") {
|
||||
filter = {
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: {
|
||||
value: "license_validation_error",
|
||||
matchType: "EXACT",
|
||||
},
|
||||
},
|
||||
};
|
||||
} else {
|
||||
filter = {
|
||||
orGroup: {
|
||||
expressions: [
|
||||
{
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: { value: "transcription_failed", matchType: "EXACT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: {
|
||||
value: "license_validation_error",
|
||||
matchType: "EXACT",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const data = await runReport(
|
||||
["eventName"],
|
||||
["eventCount"],
|
||||
dateRange,
|
||||
filter,
|
||||
100
|
||||
);
|
||||
|
||||
console.log(`❌ Errors (${period})\n`);
|
||||
|
||||
if (data.results.length === 0) {
|
||||
console.log("No errors found! 🎉");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of data.results) {
|
||||
console.log(`${row.eventName}: ${row.eventCount}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function transcriptions(period: string, breakdownBy: string) {
|
||||
const dateRange = getDateRange(period);
|
||||
const filter = {
|
||||
orGroup: {
|
||||
expressions: [
|
||||
{
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: { value: "transcription_started", matchType: "EXACT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: { value: "transcription_completed", matchType: "EXACT" },
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: {
|
||||
fieldName: "eventName",
|
||||
stringFilter: { value: "transcription_failed", matchType: "EXACT" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
console.log(`🎤 Transcriptions (${period})\n`);
|
||||
|
||||
if (breakdownBy === "day") {
|
||||
// Group by date
|
||||
const data = await runReport(
|
||||
["date", "eventName"],
|
||||
["eventCount"],
|
||||
dateRange,
|
||||
filter,
|
||||
500
|
||||
);
|
||||
|
||||
const byDate: Record<string, Record<string, number>> = {};
|
||||
for (const row of data.results) {
|
||||
const date = row.date;
|
||||
const event = row.eventName.replace("transcription_", "");
|
||||
if (!byDate[date]) byDate[date] = {};
|
||||
byDate[date][event] = parseInt(row.eventCount);
|
||||
}
|
||||
|
||||
console.log("Date".padEnd(12) + "Started".padStart(10) + "Completed".padStart(12) + "Failed".padStart(10) + "Success".padStart(10));
|
||||
console.log("-".repeat(54));
|
||||
|
||||
const dates = Object.entries(byDate).sort(([a], [b]) => b.localeCompare(a));
|
||||
for (const [date, events] of dates) {
|
||||
const started = events.started || 0;
|
||||
const completed = events.completed || 0;
|
||||
const failed = events.failed || 0;
|
||||
const successRate = started > 0 ? `${((completed / started) * 100).toFixed(0)}%` : "N/A";
|
||||
console.log(
|
||||
date.padEnd(12) +
|
||||
started.toString().padStart(10) +
|
||||
completed.toString().padStart(12) +
|
||||
failed.toString().padStart(10) +
|
||||
successRate.padStart(10)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Summary totals
|
||||
const data = await runReport(
|
||||
["eventName"],
|
||||
["eventCount", "totalUsers"],
|
||||
dateRange,
|
||||
filter,
|
||||
100
|
||||
);
|
||||
|
||||
let started = 0, completed = 0, failed = 0;
|
||||
for (const row of data.results) {
|
||||
if (row.eventName === "transcription_started") started = parseInt(row.eventCount);
|
||||
if (row.eventName === "transcription_completed") completed = parseInt(row.eventCount);
|
||||
if (row.eventName === "transcription_failed") failed = parseInt(row.eventCount);
|
||||
}
|
||||
|
||||
const successRate = started > 0 ? ((completed / started) * 100).toFixed(1) : "N/A";
|
||||
console.log(`Started: ${started}`);
|
||||
console.log(`Completed: ${completed}`);
|
||||
console.log(`Failed: ${failed}`);
|
||||
console.log(`Success Rate: ${successRate}%`);
|
||||
}
|
||||
}
|
||||
|
||||
async function versions(period: string) {
|
||||
const dateRange = getDateRange(period);
|
||||
const data = await runReport(
|
||||
["appVersion"],
|
||||
["activeUsers", "newUsers", "sessions"],
|
||||
dateRange,
|
||||
null,
|
||||
20
|
||||
);
|
||||
|
||||
console.log(`📱 Version Adoption (${period})\n`);
|
||||
console.log(
|
||||
"Version".padEnd(20) +
|
||||
"Users".padStart(8) +
|
||||
"New".padStart(8) +
|
||||
"Sessions".padStart(10)
|
||||
);
|
||||
console.log("-".repeat(46));
|
||||
|
||||
for (const row of data.results) {
|
||||
const ver = row.appVersion || "(unknown)";
|
||||
console.log(
|
||||
ver.padEnd(20) +
|
||||
row.activeUsers.padStart(8) +
|
||||
row.newUsers.padStart(8) +
|
||||
row.sessions.padStart(10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function growth(period: string) {
|
||||
const dateRange = getDateRange(period);
|
||||
const data = await runReport(
|
||||
["date"],
|
||||
["activeUsers", "newUsers", "totalUsers", "sessions"],
|
||||
dateRange,
|
||||
null,
|
||||
100
|
||||
);
|
||||
|
||||
// Get latest vs earliest for comparison
|
||||
const sorted = data.results.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
let totalNew = 0,
|
||||
totalActive = 0;
|
||||
for (const row of sorted) {
|
||||
totalNew += parseInt(row.newUsers);
|
||||
totalActive += parseInt(row.activeUsers);
|
||||
}
|
||||
|
||||
console.log(`📈 Growth Metrics (${period})\n`);
|
||||
console.log(`Total New Users: ${totalNew}`);
|
||||
console.log(`Total Active Users: ${totalActive}`);
|
||||
console.log(`Days Tracked: ${sorted.length}`);
|
||||
|
||||
if (sorted.length >= 2) {
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
console.log(`\nFirst Day (${first.date}): ${first.activeUsers} active`);
|
||||
console.log(`Last Day (${last.date}): ${last.activeUsers} active`);
|
||||
}
|
||||
}
|
||||
|
||||
async function query(
|
||||
dimensions: string[],
|
||||
metrics: string[],
|
||||
period: string,
|
||||
from?: string,
|
||||
to?: string
|
||||
) {
|
||||
let dateRange: DateRange;
|
||||
if (from && to) {
|
||||
dateRange = { startDate: from, endDate: to };
|
||||
} else {
|
||||
dateRange = getDateRange(period);
|
||||
}
|
||||
|
||||
const data = await runReport(dimensions, metrics, dateRange, null, 500);
|
||||
|
||||
console.log(`📊 Custom Query (${from || period}${to ? ` to ${to}` : ""})\n`);
|
||||
console.log(`Rows: ${data.rowCount}\n`);
|
||||
|
||||
// Print header
|
||||
const header = [...dimensions, ...metrics].map((h) => h.padEnd(15)).join(" | ");
|
||||
console.log(header);
|
||||
console.log("-".repeat(header.length));
|
||||
|
||||
// Print rows
|
||||
for (const row of data.results) {
|
||||
const values = [...dimensions, ...metrics]
|
||||
.map((key) => String(row[key] || "").padEnd(15))
|
||||
.join(" | ");
|
||||
console.log(values);
|
||||
}
|
||||
|
||||
// Print totals if available
|
||||
if (data.totals.length > 0) {
|
||||
console.log("\nTotals:");
|
||||
for (const t of data.totals) {
|
||||
console.log(` ${t.metric}: ${t.value}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function weekly(from: string, to: string) {
|
||||
const dateRange = { startDate: from, endDate: to };
|
||||
const data = await runReport(
|
||||
["week", "date"],
|
||||
["activeUsers", "newUsers", "sessions"],
|
||||
dateRange,
|
||||
null,
|
||||
500
|
||||
);
|
||||
|
||||
// Group by week
|
||||
const byWeek: Record<string, { users: number; newUsers: number; sessions: number; dates: string[] }> = {};
|
||||
|
||||
for (const row of data.results) {
|
||||
const week = row.week;
|
||||
if (!byWeek[week]) {
|
||||
byWeek[week] = { users: 0, newUsers: 0, sessions: 0, dates: [] };
|
||||
}
|
||||
byWeek[week].users += parseInt(row.activeUsers);
|
||||
byWeek[week].newUsers += parseInt(row.newUsers);
|
||||
byWeek[week].sessions += parseInt(row.sessions);
|
||||
byWeek[week].dates.push(row.date);
|
||||
}
|
||||
|
||||
console.log(`📊 Weekly Traffic (${from} to ${to})\n`);
|
||||
console.log("Week".padEnd(6) + "Users".padStart(8) + "New".padStart(8) + "Sessions".padStart(10));
|
||||
console.log("-".repeat(32));
|
||||
|
||||
const weeks = Object.entries(byWeek).sort(([a], [b]) => a.localeCompare(b));
|
||||
for (const [week, stats] of weeks) {
|
||||
console.log(
|
||||
week.padEnd(6) +
|
||||
stats.users.toString().padStart(8) +
|
||||
stats.newUsers.toString().padStart(8) +
|
||||
stats.sessions.toString().padStart(10)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Help
|
||||
function help() {
|
||||
console.log(`
|
||||
GA4 Analytics CLI (Speakmac)
|
||||
|
||||
Usage: ga4 <command> [options]
|
||||
|
||||
Commands:
|
||||
overview [--period 30d] Key metrics overview
|
||||
realtime Active users right now
|
||||
events [--period 30d] [--limit N] Event counts
|
||||
funnel <type> [--period 30d] Funnel analysis
|
||||
Types: onboarding, transcription, permissions
|
||||
errors [--type all] [--period 7d] Error analysis
|
||||
Types: all, transcription, license
|
||||
transcriptions [--period 7d] Transcription stats
|
||||
--by day Daily breakdown
|
||||
versions [--period 14d] App version adoption
|
||||
growth [--period 30d] Growth metrics
|
||||
weekly --from YYYY-MM-DD --to YYYY-MM-DD
|
||||
Week-by-week traffic breakdown
|
||||
query --dimensions d1,d2 --metrics m1,m2 [--from DATE --to DATE]
|
||||
Custom query (like MCP ga4_custom_query)
|
||||
|
||||
Periods: today, yesterday, 7d, 14d, 28d, 30d, 90d
|
||||
|
||||
Environment:
|
||||
GOOGLE_APPLICATION_CREDENTIALS Path to service account JSON
|
||||
GA4_PROPERTY_ID GA4 property ID (default: 510572418)
|
||||
|
||||
Examples:
|
||||
ga4 overview
|
||||
ga4 realtime
|
||||
ga4 events --limit 20
|
||||
ga4 funnel onboarding
|
||||
ga4 transcriptions --by day
|
||||
ga4 versions --period 7d
|
||||
ga4 weekly --from 2025-11-01 --to 2026-01-20
|
||||
ga4 query --dimensions week --metrics activeUsers,sessions --from 2025-11-01 --to 2026-01-20
|
||||
`);
|
||||
}
|
||||
|
||||
// Main
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
|
||||
// Parse flags
|
||||
const flags: Record<string, string> = {};
|
||||
let positional: string[] = [];
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i].startsWith("--")) {
|
||||
const key = args[i].slice(2);
|
||||
flags[key] = args[i + 1] || "true";
|
||||
i++;
|
||||
} else {
|
||||
positional.push(args[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const period = flags.period || "30d";
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "overview":
|
||||
await overview(period);
|
||||
break;
|
||||
case "realtime":
|
||||
await realtime();
|
||||
break;
|
||||
case "events":
|
||||
await events(period, parseInt(flags.limit) || 50);
|
||||
break;
|
||||
case "funnel":
|
||||
const funnelType = positional[0];
|
||||
if (!funnelType) {
|
||||
console.error("Usage: ga4 funnel <type>");
|
||||
console.error("Types: onboarding, transcription, permissions");
|
||||
process.exit(1);
|
||||
}
|
||||
await funnel(funnelType, period);
|
||||
break;
|
||||
case "errors":
|
||||
await errors(flags.type || "all", flags.period || "7d");
|
||||
break;
|
||||
case "transcriptions":
|
||||
await transcriptions(flags.period || "7d", flags.by || "summary");
|
||||
break;
|
||||
case "versions":
|
||||
await versions(flags.period || "14d");
|
||||
break;
|
||||
case "growth":
|
||||
await growth(period);
|
||||
break;
|
||||
case "weekly":
|
||||
if (!flags.from || !flags.to) {
|
||||
console.error("Usage: ga4 weekly --from YYYY-MM-DD --to YYYY-MM-DD");
|
||||
process.exit(1);
|
||||
}
|
||||
await weekly(flags.from, flags.to);
|
||||
break;
|
||||
case "query":
|
||||
if (!flags.dimensions || !flags.metrics) {
|
||||
console.error("Usage: ga4 query --dimensions d1,d2 --metrics m1,m2 [--from DATE --to DATE]");
|
||||
process.exit(1);
|
||||
}
|
||||
await query(
|
||||
flags.dimensions.split(","),
|
||||
flags.metrics.split(","),
|
||||
period,
|
||||
flags.from,
|
||||
flags.to
|
||||
);
|
||||
break;
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
case undefined:
|
||||
help();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
help();
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
114
skills/lemonsqueezy/SKILL.md
Normal file
114
skills/lemonsqueezy/SKILL.md
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
name: lemonsqueezy
|
||||
description: LemonSqueezy CLI for revenue, orders, subscriptions, and customer data.
|
||||
homepage: https://lemonsqueezy.com
|
||||
metadata: {"clawdbot":{"emoji":"🍋","requires":{"bins":["bun"],"env":["LEMONSQUEEZY_API_KEY"]},"primaryEnv":"LEMONSQUEEZY_API_KEY"}}
|
||||
---
|
||||
|
||||
# LemonSqueezy CLI
|
||||
|
||||
Query revenue, orders, subscriptions, and customers from LemonSqueezy.
|
||||
|
||||
## Setup
|
||||
|
||||
Set the API key:
|
||||
```bash
|
||||
export LEMONSQUEEZY_API_KEY="your_api_key"
|
||||
```
|
||||
|
||||
Or configure in `~/.clawdbot/clawdbot.json`:
|
||||
```json
|
||||
{
|
||||
"skills": {
|
||||
"entries": {
|
||||
"lemonsqueezy": {
|
||||
"apiKey": "your_api_key"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### Revenue Overview
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts overview
|
||||
```
|
||||
|
||||
### Orders
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts orders # Recent orders
|
||||
bun {baseDir}/scripts/cli.ts orders --limit 10 # Limit results
|
||||
bun {baseDir}/scripts/cli.ts orders --status paid # Filter: paid/refunded/pending
|
||||
bun {baseDir}/scripts/cli.ts orders --from 2025-11-01 --to 2026-01-20 # Date range with week-by-week summary
|
||||
```
|
||||
|
||||
### Order Lookup
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts order 12345 # By order number
|
||||
bun {baseDir}/scripts/cli.ts order customer@email.com # By customer email
|
||||
```
|
||||
|
||||
### Subscriptions
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts subs # All with status breakdown
|
||||
bun {baseDir}/scripts/cli.ts subs --status active # Filter by status
|
||||
```
|
||||
|
||||
Status values: active, cancelled, expired, on_trial, paused, past_due, unpaid
|
||||
|
||||
### MRR Breakdown
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts mrr # MRR by product/variant
|
||||
```
|
||||
|
||||
### Customers
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts customers # Customer list with LTV
|
||||
bun {baseDir}/scripts/cli.ts customers --limit 50
|
||||
```
|
||||
|
||||
### Products
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts products # All products with URLs
|
||||
```
|
||||
|
||||
### Refunds
|
||||
```bash
|
||||
bun {baseDir}/scripts/cli.ts refunds # Refunded orders
|
||||
bun {baseDir}/scripts/cli.ts refunds --limit 10
|
||||
```
|
||||
|
||||
## Output Icons
|
||||
|
||||
- ✓ = paid
|
||||
- ↩ = refunded
|
||||
- ○ = pending
|
||||
- ● = active subscription
|
||||
- ◐ = other status
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# Daily revenue check
|
||||
bun {baseDir}/scripts/cli.ts overview
|
||||
|
||||
# Recent sales
|
||||
bun {baseDir}/scripts/cli.ts orders --limit 5
|
||||
|
||||
# Find customer orders
|
||||
bun {baseDir}/scripts/cli.ts order support@example.com
|
||||
|
||||
# Subscription health
|
||||
bun {baseDir}/scripts/cli.ts subs --status active
|
||||
|
||||
# Track refunds
|
||||
bun {baseDir}/scripts/cli.ts refunds
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Revenue displayed in USD
|
||||
- API rate limit: 300 calls/minute
|
||||
- See `{baseDir}/docs/API_REFERENCE.md` for full API documentation
|
||||
395
skills/lemonsqueezy/scripts/cli.ts
Normal file
395
skills/lemonsqueezy/scripts/cli.ts
Normal file
@ -0,0 +1,395 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* LemonSqueezy CLI - Revenue, orders, subscriptions at your fingertips
|
||||
* Usage: bun skills/lemonsqueezy/cli.ts <command> [options]
|
||||
*/
|
||||
|
||||
const API_KEY = process.env.LEMONSQUEEZY_API_KEY;
|
||||
const BASE_URL = "https://api.lemonsqueezy.com/v1";
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("Error: LEMONSQUEEZY_API_KEY environment variable required");
|
||||
console.error("Set it in your shell or source from SECRETS.env");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function api(endpoint: string, params: Record<string, string> = {}) {
|
||||
const url = new URL(`${BASE_URL}${endpoint}`);
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v) url.searchParams.append(k, v);
|
||||
});
|
||||
|
||||
const res = await fetch(url.toString(), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${API_KEY}`,
|
||||
Accept: "application/vnd.api+json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`API error: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
function cents(n: number): string {
|
||||
return `$${(n / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
// Commands
|
||||
async function overview() {
|
||||
const { data } = await api("/stores");
|
||||
const store = data[0]?.attributes;
|
||||
if (!store) {
|
||||
console.log("No stores found");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`📊 ${store.name} Revenue Overview\n`);
|
||||
console.log(`Total Revenue: ${cents(store.total_revenue)}`);
|
||||
console.log(`Total Sales: ${store.total_sales}`);
|
||||
console.log(`30-Day Revenue: ${cents(store.thirty_day_revenue)}`);
|
||||
console.log(`30-Day Sales: ${store.thirty_day_sales}`);
|
||||
}
|
||||
|
||||
async function orders(limit = 20, status?: string, from?: string, to?: string) {
|
||||
const params: Record<string, string> = { "page[size]": String(limit) };
|
||||
if (status) params["filter[status]"] = status;
|
||||
|
||||
const { data } = await api("/orders", params);
|
||||
|
||||
// Filter by date if provided
|
||||
let filtered = data;
|
||||
if (from || to) {
|
||||
const fromDate = from ? new Date(from) : new Date(0);
|
||||
const toDate = to ? new Date(to) : new Date();
|
||||
toDate.setHours(23, 59, 59, 999); // End of day
|
||||
filtered = data.filter((order: any) => {
|
||||
const created = new Date(order.attributes.created_at);
|
||||
return created >= fromDate && created <= toDate;
|
||||
});
|
||||
}
|
||||
|
||||
const dateRange = from || to ? ` (${from || "start"} to ${to || "now"})` : "";
|
||||
console.log(`📦 Orders${dateRange} (${filtered.length})\n`);
|
||||
|
||||
// Group by week for summary if date range specified
|
||||
if (from && to) {
|
||||
const byWeek: Record<string, { count: number; revenue: number }> = {};
|
||||
for (const order of filtered) {
|
||||
const a = order.attributes;
|
||||
const date = new Date(a.created_at);
|
||||
const week = getWeekNumber(date);
|
||||
const key = `${date.getFullYear()}-W${week.toString().padStart(2, "0")}`;
|
||||
if (!byWeek[key]) byWeek[key] = { count: 0, revenue: 0 };
|
||||
byWeek[key].count++;
|
||||
byWeek[key].revenue += parseInt(a.total_usd || 0) / 100;
|
||||
}
|
||||
|
||||
console.log("Week".padEnd(10) + "Orders".padStart(8) + "Revenue".padStart(12));
|
||||
console.log("-".repeat(30));
|
||||
const weeks = Object.entries(byWeek).sort(([a], [b]) => a.localeCompare(b));
|
||||
for (const [week, stats] of weeks) {
|
||||
console.log(
|
||||
week.padEnd(10) +
|
||||
stats.count.toString().padStart(8) +
|
||||
`$${stats.revenue.toFixed(2)}`.padStart(12)
|
||||
);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
// Individual orders
|
||||
for (const order of filtered) {
|
||||
const a = order.attributes;
|
||||
const statusIcon = a.status === "paid" ? "✓" : a.status === "refunded" ? "↩" : "○";
|
||||
console.log(
|
||||
`${statusIcon} #${a.order_number} | ${a.total_formatted.padEnd(10)} | ${a.user_email} | ${formatDate(a.created_at)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getWeekNumber(date: Date): number {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
async function orderLookup(query: string) {
|
||||
// Try by order number first, then by email
|
||||
let orders: any[] = [];
|
||||
|
||||
if (query.includes("@")) {
|
||||
const { data } = await api("/orders", { "filter[user_email]": query });
|
||||
orders = data;
|
||||
} else {
|
||||
const { data } = await api("/orders");
|
||||
orders = data.filter(
|
||||
(o: any) => o.attributes.order_number.toString() === query
|
||||
);
|
||||
}
|
||||
|
||||
if (orders.length === 0) {
|
||||
console.log(`No orders found for: ${query}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔍 Orders for ${query}\n`);
|
||||
for (const order of orders) {
|
||||
const a = order.attributes;
|
||||
console.log(`Order #${a.order_number}`);
|
||||
console.log(` Status: ${a.status}${a.refunded ? " (refunded)" : ""}`);
|
||||
console.log(` Total: ${a.total_formatted}`);
|
||||
console.log(` Customer: ${a.user_name} <${a.user_email}>`);
|
||||
console.log(` Date: ${formatDate(a.created_at)}`);
|
||||
if (a.refunded_at) console.log(` Refunded: ${formatDate(a.refunded_at)}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
async function subscriptions(status?: string) {
|
||||
const params: Record<string, string> = { "page[size]": "100" };
|
||||
if (status) params["filter[status]"] = status;
|
||||
|
||||
const { data } = await api("/subscriptions", params);
|
||||
|
||||
// Calculate MRR
|
||||
let mrr = 0;
|
||||
const byStatus: Record<string, number> = {};
|
||||
|
||||
for (const sub of data) {
|
||||
const s = sub.attributes;
|
||||
byStatus[s.status] = (byStatus[s.status] || 0) + 1;
|
||||
if (s.status === "active") {
|
||||
// Assuming monthly - would need price endpoint for accuracy
|
||||
mrr += parseInt(s.first_subscription_item?.price || 0) / 100;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`💳 Subscriptions (${data.length})\n`);
|
||||
console.log("By Status:");
|
||||
for (const [st, count] of Object.entries(byStatus)) {
|
||||
console.log(` ${st}: ${count}`);
|
||||
}
|
||||
console.log(`\nEstimated MRR: $${mrr.toFixed(2)}\n`);
|
||||
|
||||
if (data.length > 0) {
|
||||
console.log("Recent:");
|
||||
for (const sub of data.slice(0, 10)) {
|
||||
const s = sub.attributes;
|
||||
const icon =
|
||||
s.status === "active" ? "●" : s.status === "cancelled" ? "○" : "◐";
|
||||
console.log(
|
||||
` ${icon} ${s.user_email.padEnd(30)} | ${s.status.padEnd(10)} | ${s.product_name}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function mrr() {
|
||||
const { data } = await api("/subscriptions", {
|
||||
"filter[status]": "active",
|
||||
"page[size]": "100",
|
||||
});
|
||||
|
||||
const byProduct: Record<string, { count: number; mrr: number }> = {};
|
||||
|
||||
for (const sub of data) {
|
||||
const s = sub.attributes;
|
||||
const key = `${s.product_name} - ${s.variant_name}`;
|
||||
if (!byProduct[key]) byProduct[key] = { count: 0, mrr: 0 };
|
||||
byProduct[key].count++;
|
||||
// Price from first_subscription_item
|
||||
const price = parseInt(s.first_subscription_item?.price || 0) / 100;
|
||||
byProduct[key].mrr += price;
|
||||
}
|
||||
|
||||
const sorted = Object.entries(byProduct).sort(
|
||||
([, a], [, b]) => b.mrr - a.mrr
|
||||
);
|
||||
const totalMRR = sorted.reduce((sum, [, v]) => sum + v.mrr, 0);
|
||||
|
||||
console.log(`💰 MRR Breakdown\n`);
|
||||
console.log(`Total MRR: $${totalMRR.toFixed(2)}`);
|
||||
console.log(`Active Subscribers: ${data.length}\n`);
|
||||
|
||||
for (const [product, stats] of sorted) {
|
||||
console.log(`${product}`);
|
||||
console.log(` Subscribers: ${stats.count}`);
|
||||
console.log(` MRR: $${stats.mrr.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function customers(limit = 20) {
|
||||
const { data } = await api("/customers", { "page[size]": String(limit) });
|
||||
|
||||
let totalLTV = 0;
|
||||
console.log(`👥 Customers (${data.length})\n`);
|
||||
|
||||
for (const cust of data) {
|
||||
const c = cust.attributes;
|
||||
const ltv = parseInt(c.total_revenue_currency || 0) / 100;
|
||||
totalLTV += ltv;
|
||||
console.log(
|
||||
`${c.email.padEnd(35)} | LTV: $${ltv.toFixed(2).padStart(8)} | ${c.status}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\nTotal LTV: $${totalLTV.toFixed(2)}`);
|
||||
}
|
||||
|
||||
async function products() {
|
||||
const { data } = await api("/products");
|
||||
|
||||
console.log(`📦 Products (${data.length})\n`);
|
||||
for (const prod of data) {
|
||||
const p = prod.attributes;
|
||||
const statusIcon = p.status === "published" ? "●" : "○";
|
||||
console.log(`${statusIcon} ${p.name}`);
|
||||
console.log(` Price: ${p.price_formatted || "varies"}`);
|
||||
console.log(` URL: ${p.buy_now_url}`);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
async function refunds(limit = 20) {
|
||||
// Fetch all orders and filter for refunded ones client-side
|
||||
// (LemonSqueezy API filter[refunded] is unreliable)
|
||||
const { data } = await api("/orders", {
|
||||
"page[size]": String(Math.min(limit * 5, 100)), // Fetch more to find refunds
|
||||
});
|
||||
|
||||
const refunded = data.filter((o: any) =>
|
||||
o.attributes.refunded || o.attributes.status === "refunded"
|
||||
).slice(0, limit);
|
||||
|
||||
const total = refunded.reduce(
|
||||
(sum: number, o: any) => sum + parseInt(o.attributes.total_usd || 0),
|
||||
0
|
||||
);
|
||||
|
||||
console.log(`↩️ Refunds (${refunded.length})\n`);
|
||||
console.log(`Total Refunded: ${cents(total)}\n`);
|
||||
|
||||
if (refunded.length === 0) {
|
||||
console.log("No refunds found.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const order of refunded) {
|
||||
const a = order.attributes;
|
||||
console.log(
|
||||
`#${a.order_number} | ${a.total_formatted.padEnd(10)} | ${a.user_email} | ${formatDate(a.refunded_at || a.created_at)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Help
|
||||
function help() {
|
||||
console.log(`
|
||||
LemonSqueezy CLI
|
||||
|
||||
Usage: ls <command> [options]
|
||||
|
||||
Commands:
|
||||
overview Revenue overview (total + 30-day)
|
||||
orders [--limit N] Recent orders (default: 20)
|
||||
orders --status X Filter by status (paid/refunded/pending)
|
||||
orders --from DATE --to DATE
|
||||
Orders in date range with week-by-week summary
|
||||
order <num|email> Lookup by order number or customer email
|
||||
subs [--status X] Subscriptions list
|
||||
mrr MRR breakdown by product
|
||||
customers [--limit N] Customer list with LTV
|
||||
products All products
|
||||
refunds [--limit N] Refunded orders
|
||||
|
||||
Environment:
|
||||
LEMONSQUEEZY_API_KEY API key (required)
|
||||
|
||||
Examples:
|
||||
ls overview
|
||||
ls orders --limit 10
|
||||
ls orders --from 2025-11-01 --to 2026-01-20
|
||||
ls order 12345
|
||||
ls order customer@example.com
|
||||
ls subs --status active
|
||||
ls mrr
|
||||
`);
|
||||
}
|
||||
|
||||
// Main
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const cmd = args[0];
|
||||
|
||||
// Parse flags
|
||||
const flags: Record<string, string> = {};
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
if (args[i].startsWith("--")) {
|
||||
const key = args[i].slice(2);
|
||||
flags[key] = args[i + 1] || "true";
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
switch (cmd) {
|
||||
case "overview":
|
||||
await overview();
|
||||
break;
|
||||
case "orders":
|
||||
await orders(parseInt(flags.limit) || 100, flags.status, flags.from, flags.to);
|
||||
break;
|
||||
case "order":
|
||||
const query = args[1];
|
||||
if (!query) {
|
||||
console.error("Usage: ls order <order_number|email>");
|
||||
process.exit(1);
|
||||
}
|
||||
await orderLookup(query);
|
||||
break;
|
||||
case "subs":
|
||||
case "subscriptions":
|
||||
await subscriptions(flags.status);
|
||||
break;
|
||||
case "mrr":
|
||||
await mrr();
|
||||
break;
|
||||
case "customers":
|
||||
await customers(parseInt(flags.limit) || 20);
|
||||
break;
|
||||
case "products":
|
||||
await products();
|
||||
break;
|
||||
case "refunds":
|
||||
await refunds(parseInt(flags.limit) || 20);
|
||||
break;
|
||||
case "help":
|
||||
case "--help":
|
||||
case "-h":
|
||||
case undefined:
|
||||
help();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${cmd}`);
|
||||
help();
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : err}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@ -223,6 +223,7 @@ export const dispatchTelegramMessage = async ({
|
||||
chunkMode,
|
||||
onVoiceRecording: sendRecordVoice,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
notifyEmptyResponse: info.kind === "final",
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@ -484,7 +484,7 @@ export const registerTelegramNativeCommands = ({
|
||||
cfg,
|
||||
dispatcherOptions: {
|
||||
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
|
||||
deliver: async (payload) => {
|
||||
deliver: async (payload, info) => {
|
||||
await deliverReplies({
|
||||
replies: [payload],
|
||||
chatId: String(chatId),
|
||||
@ -497,6 +497,7 @@ export const registerTelegramNativeCommands = ({
|
||||
tableMode,
|
||||
chunkMode,
|
||||
linkPreview: telegramCfg.linkPreview,
|
||||
notifyEmptyResponse: info.kind === "final",
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
|
||||
@ -42,12 +42,15 @@ export async function deliverReplies(params: {
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
/** Controls whether link previews are shown. Default: true (previews enabled). */
|
||||
linkPreview?: boolean;
|
||||
}) {
|
||||
/** If true, send a fallback message when all replies are empty. Default: false */
|
||||
notifyEmptyResponse?: boolean;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const { replies, chatId, runtime, bot, replyToMode, textLimit, messageThreadId, linkPreview } =
|
||||
params;
|
||||
const chunkMode = params.chunkMode ?? "length";
|
||||
const threadParams = buildTelegramThreadParams(messageThreadId);
|
||||
let hasReplied = false;
|
||||
let skippedEmpty = 0;
|
||||
const chunkText = (markdown: string) => {
|
||||
const markdownChunks =
|
||||
chunkMode === "newline"
|
||||
@ -75,6 +78,7 @@ export async function deliverReplies(params: {
|
||||
continue;
|
||||
}
|
||||
runtime.error?.(danger("reply missing text/media"));
|
||||
skippedEmpty++;
|
||||
continue;
|
||||
}
|
||||
const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId);
|
||||
@ -255,6 +259,18 @@ export async function deliverReplies(params: {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If all replies were empty and notifyEmptyResponse is enabled, send a fallback message
|
||||
// Check both: (1) replies with no content (skippedEmpty), (2) no replies at all (empty array)
|
||||
if (!hasReplied && (skippedEmpty > 0 || replies.length === 0) && params.notifyEmptyResponse) {
|
||||
const fallbackText = "No response generated. Please try again.";
|
||||
await sendTelegramText(bot, chatId, fallbackText, runtime, {
|
||||
messageThreadId,
|
||||
});
|
||||
hasReplied = true;
|
||||
}
|
||||
|
||||
return { delivered: hasReplied };
|
||||
}
|
||||
|
||||
export async function resolveMedia(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user