Merge branch 'fix/telegram-empty-response-notification'

This commit is contained in:
kiranjd 2026-01-27 21:44:47 +05:30
commit 60453c912e
10 changed files with 1407 additions and 7 deletions

View File

@ -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": {

View 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
View File

@ -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
View 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
View 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();

View 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

View 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();

View File

@ -223,6 +223,7 @@ export const dispatchTelegramMessage = async ({
chunkMode,
onVoiceRecording: sendRecordVoice,
linkPreview: telegramCfg.linkPreview,
notifyEmptyResponse: info.kind === "final",
});
},
onError: (err, info) => {

View File

@ -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) => {

View File

@ -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(