@heyamiko/amiko-cli 0.14.0-beta.9 → 0.14.2
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/README.md +162 -18
- package/dist/index.js +2093 -225
- package/package.json +1 -1
- package/skills/SKILL.md +126 -16
package/dist/index.js
CHANGED
|
@@ -25861,27 +25861,50 @@ function readFromFile(path) {
|
|
|
25861
25861
|
twinId: asString(parsed.amikoTwinId) ?? asString(parsed.AMIKO_TWIN_ID),
|
|
25862
25862
|
userId: asString(parsed.amikoUserId) ?? asString(parsed.AMIKO_USER_ID),
|
|
25863
25863
|
platformUrl: asString(parsed.amikoPlatformUrl) ?? asString(parsed.AMIKO_PLATFORM_URL),
|
|
25864
|
-
apiBase: asString(parsed.amikoApiBase) ?? asString(parsed.AMIKO_API_BASE) ?? asString(parsed.apiBase)
|
|
25864
|
+
apiBase: asString(parsed.amikoApiBase) ?? asString(parsed.AMIKO_API_BASE) ?? asString(parsed.apiBase),
|
|
25865
|
+
billingApiKey: asString(parsed.amikoApiKey) ?? asString(parsed.AMIKO_API_KEY) ?? asString(parsed.billingApiKey),
|
|
25866
|
+
billingUrl: asString(parsed.amikoBillingUrl) ?? asString(parsed.AMIKO_BILLING_URL) ?? asString(parsed.billingUrl)
|
|
25865
25867
|
};
|
|
25866
25868
|
} catch {
|
|
25867
25869
|
return null;
|
|
25868
25870
|
}
|
|
25869
25871
|
}
|
|
25872
|
+
function readRoutingIdsFromEnv() {
|
|
25873
|
+
return {
|
|
25874
|
+
agentId: asString(process.env.OPENHERMIT_AGENT_ID),
|
|
25875
|
+
sessionId: asString(process.env.OPENHERMIT_SESSION_ID)
|
|
25876
|
+
};
|
|
25877
|
+
}
|
|
25870
25878
|
function readFromEnv() {
|
|
25871
25879
|
return {
|
|
25872
25880
|
twinToken: asString(process.env.AMIKO_TWIN_TOKEN) ?? asString(process.env.AMIKO_USER_TOKEN),
|
|
25873
25881
|
twinId: asString(process.env.AMIKO_TWIN_ID),
|
|
25874
25882
|
userId: asString(process.env.AMIKO_USER_ID),
|
|
25875
25883
|
platformUrl: asString(process.env.AMIKO_PLATFORM_URL),
|
|
25876
|
-
apiBase: asString(process.env.AMIKO_API_BASE)
|
|
25884
|
+
apiBase: asString(process.env.AMIKO_API_BASE),
|
|
25885
|
+
billingApiKey: asString(process.env.AMIKO_API_KEY),
|
|
25886
|
+
billingUrl: asString(process.env.AMIKO_BILLING_URL),
|
|
25887
|
+
...readRoutingIdsFromEnv()
|
|
25888
|
+
};
|
|
25889
|
+
}
|
|
25890
|
+
function resolveFileAuth(path) {
|
|
25891
|
+
const fromFile = readFromFile(path);
|
|
25892
|
+
if (!fromFile)
|
|
25893
|
+
return null;
|
|
25894
|
+
const fromEnv = readFromEnv();
|
|
25895
|
+
return {
|
|
25896
|
+
...fromFile,
|
|
25897
|
+
...readRoutingIdsFromEnv(),
|
|
25898
|
+
billingApiKey: fromEnv.billingApiKey ?? fromFile.billingApiKey,
|
|
25899
|
+
billingUrl: fromEnv.billingUrl ?? fromFile.billingUrl
|
|
25877
25900
|
};
|
|
25878
25901
|
}
|
|
25879
25902
|
function resolveAuth() {
|
|
25880
25903
|
const filePath = findAmikoJsonPath();
|
|
25881
25904
|
if (filePath) {
|
|
25882
|
-
const
|
|
25883
|
-
if (
|
|
25884
|
-
return
|
|
25905
|
+
const merged = resolveFileAuth(filePath);
|
|
25906
|
+
if (merged)
|
|
25907
|
+
return merged;
|
|
25885
25908
|
}
|
|
25886
25909
|
return readFromEnv();
|
|
25887
25910
|
}
|
|
@@ -27611,7 +27634,31 @@ function registerBridgeCommand(program2) {
|
|
|
27611
27634
|
import readline2 from "node:readline";
|
|
27612
27635
|
|
|
27613
27636
|
// src/lib/balance.ts
|
|
27614
|
-
|
|
27637
|
+
var DEFAULT_BILLING_URL = "https://billing.heyamiko.com";
|
|
27638
|
+
function billingBase(auth) {
|
|
27639
|
+
const url = auth.billingUrl || DEFAULT_BILLING_URL;
|
|
27640
|
+
return url.replace(/\/$/, "");
|
|
27641
|
+
}
|
|
27642
|
+
async function fetchCreditsBalanceViaAmk(auth) {
|
|
27643
|
+
const apiKey = auth.billingApiKey;
|
|
27644
|
+
if (!apiKey || !apiKey.startsWith("amk_"))
|
|
27645
|
+
return null;
|
|
27646
|
+
try {
|
|
27647
|
+
const res = await fetch(`${billingBase(auth)}/api/credits`, {
|
|
27648
|
+
method: "GET",
|
|
27649
|
+
headers: { Authorization: `Bearer ${apiKey}` },
|
|
27650
|
+
signal: AbortSignal.timeout(15000)
|
|
27651
|
+
});
|
|
27652
|
+
if (!res.ok)
|
|
27653
|
+
return null;
|
|
27654
|
+
const data = await res.json();
|
|
27655
|
+
const raw = data.displayBalance;
|
|
27656
|
+
return typeof raw === "number" && isFinite(raw) ? Math.round(raw) : null;
|
|
27657
|
+
} catch {
|
|
27658
|
+
return null;
|
|
27659
|
+
}
|
|
27660
|
+
}
|
|
27661
|
+
async function fetchCreditsBalanceViaPlatformProxy(auth) {
|
|
27615
27662
|
if (!auth.twinToken)
|
|
27616
27663
|
return null;
|
|
27617
27664
|
try {
|
|
@@ -27622,6 +27669,12 @@ async function fetchCreditsBalance(auth) {
|
|
|
27622
27669
|
return null;
|
|
27623
27670
|
}
|
|
27624
27671
|
}
|
|
27672
|
+
async function fetchCreditsBalance(auth) {
|
|
27673
|
+
const viaAmk = await fetchCreditsBalanceViaAmk(auth);
|
|
27674
|
+
if (viaAmk != null)
|
|
27675
|
+
return viaAmk;
|
|
27676
|
+
return fetchCreditsBalanceViaPlatformProxy(auth);
|
|
27677
|
+
}
|
|
27625
27678
|
async function printBalanceRemaining(auth) {
|
|
27626
27679
|
const balance = await fetchCreditsBalance(auth);
|
|
27627
27680
|
if (balance == null) {
|
|
@@ -28637,6 +28690,7 @@ function registerImageCommand(program2) {
|
|
|
28637
28690
|
}
|
|
28638
28691
|
|
|
28639
28692
|
// src/commands/create.ts
|
|
28693
|
+
import { randomUUID } from "node:crypto";
|
|
28640
28694
|
var ETA = {
|
|
28641
28695
|
IMAGE: "~15–60s",
|
|
28642
28696
|
VIDEO: "~2–9 min",
|
|
@@ -28647,6 +28701,13 @@ var ETA = {
|
|
|
28647
28701
|
function statusOf(err) {
|
|
28648
28702
|
return err?.status;
|
|
28649
28703
|
}
|
|
28704
|
+
function autoApproveLimit() {
|
|
28705
|
+
const raw = process.env.AMIKO_AUTO_APPROVE_LIMIT;
|
|
28706
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
28707
|
+
return;
|
|
28708
|
+
const limit = Number(raw);
|
|
28709
|
+
return Number.isFinite(limit) ? limit : undefined;
|
|
28710
|
+
}
|
|
28650
28711
|
function collectOption(value, previous) {
|
|
28651
28712
|
return [...previous ?? [], value];
|
|
28652
28713
|
}
|
|
@@ -28656,7 +28717,7 @@ function assertMaxUrls(resourceName, urls, max) {
|
|
|
28656
28717
|
process.exit(1);
|
|
28657
28718
|
}
|
|
28658
28719
|
}
|
|
28659
|
-
function printCompleted(final, title) {
|
|
28720
|
+
function printCompleted(final, title, deliveredToThisSession = false) {
|
|
28660
28721
|
console.log(heading(`${title}
|
|
28661
28722
|
`));
|
|
28662
28723
|
if (final.prompt)
|
|
@@ -28665,6 +28726,9 @@ function printCompleted(final, title) {
|
|
|
28665
28726
|
console.log(label("URL", final.assetUrl));
|
|
28666
28727
|
if (final.assetMime)
|
|
28667
28728
|
console.log(label("Type", final.assetMime));
|
|
28729
|
+
if (deliveredToThisSession) {
|
|
28730
|
+
console.log(success("Already delivered to the user in this chat automatically. Do NOT send this file again and do NOT paste the URL — you would duplicate it."));
|
|
28731
|
+
}
|
|
28668
28732
|
console.log("");
|
|
28669
28733
|
if (final.charged) {
|
|
28670
28734
|
const amt = final.chargeAmount ?? final.costAmiko ?? "?";
|
|
@@ -28673,6 +28737,9 @@ function printCompleted(final, title) {
|
|
|
28673
28737
|
} else {
|
|
28674
28738
|
console.log(dim("Not charged (settlement pending or waived)."));
|
|
28675
28739
|
}
|
|
28740
|
+
if (final.driveDocId) {
|
|
28741
|
+
console.log(label("Saved to drive", final.driveDocId) + dim(" (amiko drive get " + final.driveDocId + ")"));
|
|
28742
|
+
}
|
|
28676
28743
|
console.log(dim("View in your media space: https://platform.heyamiko.com/media"));
|
|
28677
28744
|
}
|
|
28678
28745
|
async function submitCreateJob(opts) {
|
|
@@ -28683,7 +28750,17 @@ async function submitCreateJob(opts) {
|
|
|
28683
28750
|
console.error(error(e5 instanceof Error ? e5.message : "auth failed"));
|
|
28684
28751
|
process.exit(1);
|
|
28685
28752
|
}
|
|
28686
|
-
|
|
28753
|
+
const payMethod = (opts.pay ?? "credits").toLowerCase();
|
|
28754
|
+
if (payMethod !== "wallet" && payMethod !== "credits") {
|
|
28755
|
+
console.error(error(`Unknown --pay "${opts.pay}". Allowed: wallet, credits.`));
|
|
28756
|
+
process.exit(1);
|
|
28757
|
+
}
|
|
28758
|
+
const payCredits = payMethod === "credits";
|
|
28759
|
+
if (payCredits && opts.jobBody.preferredToken) {
|
|
28760
|
+
console.error(dim("--token is ignored with --pay credits (credits come from your Amiko account, not a wallet token)."));
|
|
28761
|
+
}
|
|
28762
|
+
let costLine = payCredits ? "reserved from your Amiko account credits, captured on success (released on failure)" : "charged from your twin wallet on success (nothing on failure)";
|
|
28763
|
+
let quotedCostAmiko;
|
|
28687
28764
|
try {
|
|
28688
28765
|
const q = await amikoWebFetch(auth, "/api/create/quote", {
|
|
28689
28766
|
method: "POST",
|
|
@@ -28691,13 +28768,32 @@ async function submitCreateJob(opts) {
|
|
|
28691
28768
|
timeoutMs: 15000
|
|
28692
28769
|
});
|
|
28693
28770
|
if (q.cost_usd != null) {
|
|
28694
|
-
costLine = `${q.cost_amiko ?? "?"} AMIKO (≈ $${q.cost_usd.toFixed(4)}) — charged only on success`;
|
|
28771
|
+
costLine = payCredits ? `≈ ${Math.ceil(q.cost_usd * 1e4)} Amiko credits (≈ $${q.cost_usd.toFixed(4)}) — reserved on submit, captured only on success` : `${q.cost_amiko ?? "?"} AMIKO (≈ $${q.cost_usd.toFixed(4)}) — charged only on success`;
|
|
28772
|
+
}
|
|
28773
|
+
if (typeof q.cost_amiko === "string" && q.cost_amiko.trim() !== "") {
|
|
28774
|
+
const n = Number(q.cost_amiko);
|
|
28775
|
+
if (Number.isFinite(n))
|
|
28776
|
+
quotedCostAmiko = n;
|
|
28695
28777
|
}
|
|
28696
28778
|
} catch {}
|
|
28779
|
+
const preferredToken = typeof opts.jobBody.preferredToken === "string" ? opts.jobBody.preferredToken.toUpperCase() : undefined;
|
|
28780
|
+
const chargeIsAmiko = !payCredits && (preferredToken === undefined || preferredToken === "AMIKO");
|
|
28781
|
+
const autoApprovable = payCredits || chargeIsAmiko;
|
|
28782
|
+
const effectiveChargeToken = preferredToken ?? "AMIKO";
|
|
28783
|
+
let yes = opts.yes;
|
|
28784
|
+
let autoApproved = false;
|
|
28785
|
+
if (!yes) {
|
|
28786
|
+
const limit = autoApproveLimit();
|
|
28787
|
+
if (limit !== undefined && quotedCostAmiko !== undefined && quotedCostAmiko > 0 && autoApprovable && quotedCostAmiko <= limit) {
|
|
28788
|
+
yes = true;
|
|
28789
|
+
autoApproved = true;
|
|
28790
|
+
console.log(dim(`Auto-approved (cost ${quotedCostAmiko} ≤ limit ${limit} AMIKO${payCredits ? ", charged as account credits" : ""})`));
|
|
28791
|
+
}
|
|
28792
|
+
}
|
|
28697
28793
|
await requireApproval({
|
|
28698
28794
|
cost: costLine,
|
|
28699
28795
|
summary: opts.summary,
|
|
28700
|
-
yes
|
|
28796
|
+
yes,
|
|
28701
28797
|
commandExample: `amiko create ${opts.mode.toLowerCase()} ...`
|
|
28702
28798
|
});
|
|
28703
28799
|
const isTTY = process.stderr.isTTY;
|
|
@@ -28707,10 +28803,22 @@ async function submitCreateJob(opts) {
|
|
|
28707
28803
|
spinner.stop();
|
|
28708
28804
|
};
|
|
28709
28805
|
let jobId;
|
|
28806
|
+
let routedAgentId;
|
|
28807
|
+
let routedSessionId;
|
|
28710
28808
|
try {
|
|
28711
28809
|
const submit = await amikoWebFetch(auth, "/api/create/jobs", {
|
|
28712
28810
|
method: "POST",
|
|
28713
|
-
body: {
|
|
28811
|
+
body: {
|
|
28812
|
+
...opts.jobBody,
|
|
28813
|
+
twinId: auth.twinId,
|
|
28814
|
+
...autoApproved && !payCredits ? { preferredToken: effectiveChargeToken, strictToken: true } : {},
|
|
28815
|
+
...payCredits ? {
|
|
28816
|
+
paymentMethod: { kind: "account_credits" },
|
|
28817
|
+
requestId: randomUUID()
|
|
28818
|
+
} : {},
|
|
28819
|
+
...auth.agentId ? { agentId: auth.agentId } : {},
|
|
28820
|
+
...auth.sessionId ? { sessionId: auth.sessionId } : {}
|
|
28821
|
+
},
|
|
28714
28822
|
timeoutMs: 30000
|
|
28715
28823
|
});
|
|
28716
28824
|
if (!submit.jobId) {
|
|
@@ -28719,35 +28827,58 @@ async function submitCreateJob(opts) {
|
|
|
28719
28827
|
process.exit(1);
|
|
28720
28828
|
}
|
|
28721
28829
|
jobId = submit.jobId;
|
|
28830
|
+
routedAgentId = submit.agentId;
|
|
28831
|
+
routedSessionId = submit.sessionId;
|
|
28722
28832
|
} catch (e5) {
|
|
28723
28833
|
stop();
|
|
28724
|
-
|
|
28834
|
+
const webErr = e5;
|
|
28835
|
+
let message = e5 instanceof Error ? e5.message : "Submit failed";
|
|
28836
|
+
let errorCode;
|
|
28837
|
+
if (typeof webErr.body === "string" && webErr.body) {
|
|
28838
|
+
try {
|
|
28839
|
+
const parsed = JSON.parse(webErr.body);
|
|
28840
|
+
if (parsed.error)
|
|
28841
|
+
message = parsed.error;
|
|
28842
|
+
if (typeof parsed.code === "string")
|
|
28843
|
+
errorCode = parsed.code;
|
|
28844
|
+
} catch {}
|
|
28845
|
+
}
|
|
28846
|
+
console.error(error(message));
|
|
28847
|
+
if (payCredits && (errorCode === "ACCOUNT_CREDITS_DISABLED" || errorCode === "ACCOUNT_NOT_UNIFIED")) {
|
|
28848
|
+
console.error(dim("Account credits are not available for this account yet — retry without --pay credits to charge the twin wallet."));
|
|
28849
|
+
}
|
|
28725
28850
|
process.exit(1);
|
|
28726
28851
|
}
|
|
28852
|
+
const sessionRouted = !!(routedAgentId && routedSessionId);
|
|
28727
28853
|
stop();
|
|
28728
28854
|
if (opts.raw) {
|
|
28729
28855
|
console.log(JSON.stringify({
|
|
28730
28856
|
jobId,
|
|
28731
28857
|
status: "PENDING",
|
|
28732
28858
|
check: `amiko create status ${jobId}`,
|
|
28733
|
-
eta: ETA[opts.mode]
|
|
28859
|
+
eta: ETA[opts.mode],
|
|
28860
|
+
...sessionRouted ? { sessionRouted: true, deliveredToSession: routedSessionId } : {}
|
|
28734
28861
|
}, null, 2));
|
|
28735
28862
|
return;
|
|
28736
28863
|
}
|
|
28737
28864
|
console.log(success(`Submitted — job ${jobId}`));
|
|
28738
28865
|
console.log(label("Status", "PENDING — generating in the background"));
|
|
28739
28866
|
console.log(label("Check result", `amiko create status ${jobId}`));
|
|
28867
|
+
if (sessionRouted) {
|
|
28868
|
+
console.log(dim("A generating-placeholder is already showing in the chat and the finished media will be delivered there automatically — do not send the file or URL yourself."));
|
|
28869
|
+
}
|
|
28740
28870
|
console.log(dim(`Usually ready in ${ETA[opts.mode]} — charged only on success. (Also: amiko create media)`));
|
|
28741
28871
|
if (opts.mode === "VIDEO") {
|
|
28742
28872
|
console.log(dim("Async video: HTTP 202 / PENDING means the job was accepted — not a failure. Poll status after ~2–9 min."));
|
|
28743
28873
|
}
|
|
28744
28874
|
}
|
|
28745
28875
|
function registerCreateCommand(create2) {
|
|
28746
|
-
create2.command("image <prompt>").description("Generate an image (Create Studio)").option("--model <model>", "Image model: nano-banana-2 (default), nano-banana[-pro|-lite] (Google), gpt-image-2/-1.5/-1/-1-mini (OpenAI), grok-imagine-image-quality (xAI), image-01 (MiniMax)", "nano-banana-2").option("--aspect <ratio>", "Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4", "1:1").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
|
|
28876
|
+
create2.command("image <prompt>").description("Generate an image (Create Studio)").option("--model <model>", "Image model: nano-banana-2 (default), nano-banana[-pro|-lite] (Google), gpt-image-2/-1.5/-1/-1-mini (OpenAI), grok-imagine-image-quality (xAI), image-01 (MiniMax)", "nano-banana-2").option("--aspect <ratio>", "Aspect ratio: 1:1, 16:9, 9:16, 4:3, 3:4", "1:1").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
|
|
28747
28877
|
await submitCreateJob({
|
|
28748
28878
|
mode: "IMAGE",
|
|
28749
28879
|
summary: `Create image: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
|
|
28750
28880
|
raw: opts.raw,
|
|
28881
|
+
pay: opts.pay,
|
|
28751
28882
|
yes: opts.yes,
|
|
28752
28883
|
quoteBody: { mode: "IMAGE", model: opts.model, size: opts.aspect },
|
|
28753
28884
|
jobBody: {
|
|
@@ -28759,7 +28890,7 @@ function registerCreateCommand(create2) {
|
|
|
28759
28890
|
}
|
|
28760
28891
|
});
|
|
28761
28892
|
});
|
|
28762
|
-
create2.command("video <prompt>").description("Generate a video (Create Studio)").option("--model <model>", "Video model: MiniMax-Hailuo-2.3-Fast (default
|
|
28893
|
+
create2.command("video <prompt>").description("Generate a video (Create Studio)").option("--model <model>", "Video model: MiniMax-Hailuo-02 (default for prompt-only; T2V+I2V), MiniMax-Hailuo-2.3-Fast (default with --first-frame; I2V only), MiniMax-Hailuo-2.3 (T2V+I2V), veo-3.1[-fast|-lite]-generate-preview (Google; durations clamp to 4/6/8s), grok-imagine-video (xAI), dreamina-seedance-* (BytePlus). ONLY these — kling and other ids are not supported").option("--resolution <res>", "512P, 720P, 768P, 1080P", "768P").option("--seconds <n>", "Clip length: 6 or 10", "6").option("--aspect <ratio>", "Aspect ratio (provider-dependent)").option("--first-frame <url>", "Image-to-video: first frame URL/data URI").option("--last-frame <url>", "First-and-last-frame video: ending frame URL/data URI").option("--reference-image <url>", "Seedance multimodal: reference image URL (repeatable, max 9)", collectOption, []).option("--reference-video <url>", "Seedance multimodal: reference video URL (repeatable, max 3)", collectOption, []).option("--reference-audio <url>", "Seedance multimodal: reference audio URL (repeatable, max 3)", collectOption, []).option("--subject-reference <url>", "MiniMax S2V: subject/character reference image URL").option("--generate-audio", "Seedance: generate audio in the output video").option("--no-generate-audio", "Seedance: output silent video").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
|
|
28763
28894
|
const seconds = Number(opts.seconds);
|
|
28764
28895
|
if (seconds !== 6 && seconds !== 10) {
|
|
28765
28896
|
console.error(error("--seconds must be 6 or 10."));
|
|
@@ -28772,21 +28903,24 @@ function registerCreateCommand(create2) {
|
|
|
28772
28903
|
assertMaxUrls("--reference-video", referenceVideoUrls, 3);
|
|
28773
28904
|
assertMaxUrls("--reference-audio", referenceAudioUrls, 3);
|
|
28774
28905
|
const generateAudio = opts.generateAudio;
|
|
28906
|
+
const model = opts.model ?? (opts.firstFrame ? "MiniMax-Hailuo-2.3-Fast" : "MiniMax-Hailuo-02");
|
|
28775
28907
|
await submitCreateJob({
|
|
28776
28908
|
mode: "VIDEO",
|
|
28777
28909
|
summary: `Create video: ${prompt.slice(0, 80)}${prompt.length > 80 ? "…" : ""}`,
|
|
28778
28910
|
raw: opts.raw,
|
|
28911
|
+
pay: opts.pay,
|
|
28779
28912
|
yes: opts.yes,
|
|
28780
28913
|
quoteBody: {
|
|
28781
28914
|
mode: "VIDEO",
|
|
28782
|
-
model
|
|
28915
|
+
model,
|
|
28783
28916
|
size: opts.resolution,
|
|
28784
|
-
seconds
|
|
28917
|
+
seconds,
|
|
28918
|
+
...referenceVideoUrls.length ? { referenceVideoUrls } : {}
|
|
28785
28919
|
},
|
|
28786
28920
|
jobBody: {
|
|
28787
28921
|
mode: "VIDEO",
|
|
28788
28922
|
prompt,
|
|
28789
|
-
model
|
|
28923
|
+
model,
|
|
28790
28924
|
resolution: opts.resolution,
|
|
28791
28925
|
seconds,
|
|
28792
28926
|
...opts.aspect ? { aspectRatio: opts.aspect } : {},
|
|
@@ -28808,23 +28942,59 @@ function registerCreateCommand(create2) {
|
|
|
28808
28942
|
}
|
|
28809
28943
|
});
|
|
28810
28944
|
});
|
|
28811
|
-
|
|
28945
|
+
const DEFAULT_EL_TTS_MODEL = "eleven_multilingual_v2";
|
|
28946
|
+
const DEFAULT_EL_TTS_VOICE = "21m00Tcm4TlvDq8ikWAM";
|
|
28947
|
+
const DEFAULT_MM_TTS_MODEL = "speech-2.8-turbo";
|
|
28948
|
+
const DEFAULT_MM_TTS_VOICE = "English_expressive_narrator";
|
|
28949
|
+
const MINIMAX_TTS_MODELS = new Set([
|
|
28950
|
+
"speech-2.8-turbo",
|
|
28951
|
+
"speech-2.8-hd",
|
|
28952
|
+
"speech-2.6-turbo",
|
|
28953
|
+
"speech-2.6-hd"
|
|
28954
|
+
]);
|
|
28955
|
+
create2.command("tts <text>").description("Generate speech from text (Create Studio)").option("--provider <name>", "TTS provider: minimax | elevenlabs (sets defaults when --model/--voice omitted)").option("--model <model>", `TTS model id (MiniMax: speech-2.8-turbo|hd, speech-2.6-turbo|hd; ElevenLabs: eleven_*)`).option("--voice <voiceId>", "Voice id (MiniMax system e.g. English_expressive_narrator, or a cloned voice_id; ElevenLabs voice hash)").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (text, opts) => {
|
|
28956
|
+
const providerRaw = typeof opts.provider === "string" ? opts.provider.trim().toLowerCase() : "";
|
|
28957
|
+
if (providerRaw && providerRaw !== "minimax" && providerRaw !== "elevenlabs") {
|
|
28958
|
+
console.error(error("--provider must be minimax or elevenlabs"));
|
|
28959
|
+
process.exit(1);
|
|
28960
|
+
}
|
|
28961
|
+
let model = typeof opts.model === "string" && opts.model.trim().length > 0 ? opts.model.trim() : undefined;
|
|
28962
|
+
let voice = typeof opts.voice === "string" && opts.voice.trim().length > 0 ? opts.voice.trim() : undefined;
|
|
28963
|
+
let provider = providerRaw === "minimax" || providerRaw === "elevenlabs" ? providerRaw : model && MINIMAX_TTS_MODELS.has(model) ? "minimax" : model && model.startsWith("eleven") ? "elevenlabs" : "elevenlabs";
|
|
28964
|
+
if (!model) {
|
|
28965
|
+
model = provider === "minimax" ? DEFAULT_MM_TTS_MODEL : DEFAULT_EL_TTS_MODEL;
|
|
28966
|
+
}
|
|
28967
|
+
if (!voice) {
|
|
28968
|
+
voice = provider === "minimax" ? DEFAULT_MM_TTS_VOICE : DEFAULT_EL_TTS_VOICE;
|
|
28969
|
+
}
|
|
28970
|
+
if (MINIMAX_TTS_MODELS.has(model) && providerRaw !== "elevenlabs") {
|
|
28971
|
+
provider = "minimax";
|
|
28972
|
+
}
|
|
28973
|
+
if (provider === "minimax" && !MINIMAX_TTS_MODELS.has(model)) {
|
|
28974
|
+
console.error(error(`--provider minimax requires a MiniMax speech model (${[...MINIMAX_TTS_MODELS].join(", ")})`));
|
|
28975
|
+
process.exit(1);
|
|
28976
|
+
}
|
|
28977
|
+
if (provider === "elevenlabs" && !model.startsWith("eleven")) {
|
|
28978
|
+
console.error(error("--provider elevenlabs requires an eleven_* model"));
|
|
28979
|
+
process.exit(1);
|
|
28980
|
+
}
|
|
28812
28981
|
await submitCreateJob({
|
|
28813
28982
|
mode: "TTS",
|
|
28814
|
-
summary: `Create speech: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
|
|
28983
|
+
summary: `Create speech (${provider}/${model}): ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
|
|
28815
28984
|
raw: opts.raw,
|
|
28985
|
+
pay: opts.pay,
|
|
28816
28986
|
yes: opts.yes,
|
|
28817
|
-
quoteBody: { mode: "TTS", model
|
|
28987
|
+
quoteBody: { mode: "TTS", model, prompt: text, voiceId: voice },
|
|
28818
28988
|
jobBody: {
|
|
28819
28989
|
mode: "TTS",
|
|
28820
28990
|
prompt: text,
|
|
28821
|
-
model
|
|
28822
|
-
voiceId:
|
|
28991
|
+
model,
|
|
28992
|
+
voiceId: voice,
|
|
28823
28993
|
...opts.token ? { preferredToken: opts.token } : {}
|
|
28824
28994
|
}
|
|
28825
28995
|
});
|
|
28826
28996
|
});
|
|
28827
|
-
create2.command("music [prompt]").description("Generate music (Create Studio)").option("--model <model>", "music_v1, music-2.6[-free], music-cover[-free]", "music-2.6").option("--lyrics <lyrics>", "Lyrics (optional)").option("--duration <ms>", "Length in ms").option("--instrumental", "Instrumental (no vocals)").option("--lyrics-optimizer", "Auto-write lyrics from the prompt (music_v1 only; on by default when no --lyrics/--instrumental)").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
|
|
28997
|
+
create2.command("music [prompt]").description("Generate music (Create Studio)").option("--model <model>", "music_v1, music-2.6[-free], music-cover[-free]", "music-2.6").option("--lyrics <lyrics>", "Lyrics (optional)").option("--duration <ms>", "Length in ms").option("--instrumental", "Instrumental (no vocals)").option("--lyrics-optimizer", "Auto-write lyrics from the prompt (music_v1 only; on by default when no --lyrics/--instrumental)").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (prompt, opts) => {
|
|
28828
28998
|
if (!prompt && !opts.lyrics) {
|
|
28829
28999
|
console.error(error("Provide a prompt or --lyrics."));
|
|
28830
29000
|
process.exit(1);
|
|
@@ -28833,6 +29003,7 @@ function registerCreateCommand(create2) {
|
|
|
28833
29003
|
mode: "MUSIC",
|
|
28834
29004
|
summary: `Create music: ${(prompt ?? opts.lyrics ?? "").slice(0, 80)}`,
|
|
28835
29005
|
raw: opts.raw,
|
|
29006
|
+
pay: opts.pay,
|
|
28836
29007
|
yes: opts.yes,
|
|
28837
29008
|
quoteBody: { mode: "MUSIC", model: opts.model },
|
|
28838
29009
|
jobBody: {
|
|
@@ -28847,11 +29018,12 @@ function registerCreateCommand(create2) {
|
|
|
28847
29018
|
}
|
|
28848
29019
|
});
|
|
28849
29020
|
});
|
|
28850
|
-
create2.command("sfx <text>").description("Generate a sound effect (Create Studio)").option("--duration <seconds>", "Clip length in seconds").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (text, opts) => {
|
|
29021
|
+
create2.command("sfx <text>").description("Generate a sound effect (Create Studio)").option("--duration <seconds>", "Clip length in seconds").option("--token <symbol>", "Preferred charge token: AMIKO, USDC, USDT, SOL").option("--pay <method>", "Payment method: credits (Amiko account credits, default) or wallet (twin wallet)", "credits").option("--raw", "Output raw JSON").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (text, opts) => {
|
|
28851
29022
|
await submitCreateJob({
|
|
28852
29023
|
mode: "SFX",
|
|
28853
29024
|
summary: `Create SFX: ${text.slice(0, 80)}${text.length > 80 ? "…" : ""}`,
|
|
28854
29025
|
raw: opts.raw,
|
|
29026
|
+
pay: opts.pay,
|
|
28855
29027
|
yes: opts.yes,
|
|
28856
29028
|
quoteBody: { mode: "SFX" },
|
|
28857
29029
|
jobBody: {
|
|
@@ -28881,8 +29053,9 @@ function registerCreateCommand(create2) {
|
|
|
28881
29053
|
}
|
|
28882
29054
|
process.exit(1);
|
|
28883
29055
|
}
|
|
29056
|
+
const deliveredToThisSession = !!(p.agentId && p.sessionId && auth.sessionId && p.sessionId === auth.sessionId);
|
|
28884
29057
|
if (opts.raw) {
|
|
28885
|
-
console.log(JSON.stringify(p, null, 2));
|
|
29058
|
+
console.log(JSON.stringify(deliveredToThisSession ? { ...p, sessionRouted: true, deliveredToSession: p.sessionId } : p, null, 2));
|
|
28886
29059
|
return;
|
|
28887
29060
|
}
|
|
28888
29061
|
if (p.status === "FAILED") {
|
|
@@ -28894,7 +29067,7 @@ function registerCreateCommand(create2) {
|
|
|
28894
29067
|
console.log(dim(`Job id: ${jobId}`));
|
|
28895
29068
|
return;
|
|
28896
29069
|
}
|
|
28897
|
-
printCompleted(p, "Job complete");
|
|
29070
|
+
printCompleted(p, "Job complete", deliveredToThisSession);
|
|
28898
29071
|
});
|
|
28899
29072
|
create2.command("media").description("List your recent Create Studio generations").option("--limit <n>", "How many to show (max 50)", "20").option("--service <type>", "Filter by type: image, video, tts, music, sfx").option("--raw", "Output raw JSON").action(async (opts) => {
|
|
28900
29073
|
let auth;
|
|
@@ -28948,41 +29121,42 @@ import { basename, extname } from "node:path";
|
|
|
28948
29121
|
var MAX_BYTES = 100 * 1024 * 1024;
|
|
28949
29122
|
var FETCH_TIMEOUT_MS = 30000;
|
|
28950
29123
|
async function resolveFileInput(src, opts = {}) {
|
|
29124
|
+
const maxBytes = opts.maxBytes ?? MAX_BYTES;
|
|
28951
29125
|
if (src === "-" || opts.stdin) {
|
|
28952
|
-
const buffer2 = await readStdin();
|
|
29126
|
+
const buffer2 = await readStdin(maxBytes);
|
|
28953
29127
|
const name2 = opts.hintName ?? "stdin";
|
|
28954
29128
|
const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
|
|
28955
29129
|
return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
|
|
28956
29130
|
}
|
|
28957
29131
|
if (/^https?:\/\//i.test(src)) {
|
|
28958
|
-
const buffer2 = await fetchRemote(src);
|
|
29132
|
+
const buffer2 = await fetchRemote(src, maxBytes);
|
|
28959
29133
|
const urlPath = new URL(src).pathname;
|
|
28960
29134
|
const name2 = opts.hintName ?? (basename(urlPath) || "download");
|
|
28961
29135
|
const mime2 = opts.hintMime ?? detectMime(buffer2, name2);
|
|
28962
29136
|
return { buffer: buffer2, name: name2, mime: mime2, size: buffer2.length };
|
|
28963
29137
|
}
|
|
28964
29138
|
const buffer = await readFile(src);
|
|
28965
|
-
if (buffer.length >
|
|
28966
|
-
throw new Error(`File too large: ${buffer.length} bytes exceeds ${
|
|
29139
|
+
if (buffer.length > maxBytes) {
|
|
29140
|
+
throw new Error(`File too large: ${buffer.length} bytes exceeds ${maxBytes} byte cap.`);
|
|
28967
29141
|
}
|
|
28968
29142
|
const name = opts.hintName ?? basename(src);
|
|
28969
29143
|
const mime = opts.hintMime ?? detectMime(buffer, name);
|
|
28970
29144
|
return { buffer, name, mime, size: buffer.length };
|
|
28971
29145
|
}
|
|
28972
|
-
async function readStdin() {
|
|
29146
|
+
async function readStdin(maxBytes) {
|
|
28973
29147
|
const chunks = [];
|
|
28974
29148
|
let total = 0;
|
|
28975
29149
|
for await (const chunk of process.stdin) {
|
|
28976
29150
|
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
28977
29151
|
total += buf.length;
|
|
28978
|
-
if (total >
|
|
28979
|
-
throw new Error(`stdin exceeded ${
|
|
29152
|
+
if (total > maxBytes) {
|
|
29153
|
+
throw new Error(`stdin exceeded ${maxBytes} byte cap.`);
|
|
28980
29154
|
}
|
|
28981
29155
|
chunks.push(buf);
|
|
28982
29156
|
}
|
|
28983
29157
|
return Buffer.concat(chunks);
|
|
28984
29158
|
}
|
|
28985
|
-
async function fetchRemote(url2) {
|
|
29159
|
+
async function fetchRemote(url2, maxBytes) {
|
|
28986
29160
|
const controller = new AbortController;
|
|
28987
29161
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
28988
29162
|
try {
|
|
@@ -28991,12 +29165,12 @@ async function fetchRemote(url2) {
|
|
|
28991
29165
|
throw new Error(`Fetch ${url2} failed: ${res.status} ${res.statusText}`);
|
|
28992
29166
|
}
|
|
28993
29167
|
const len = Number(res.headers.get("content-length") || 0);
|
|
28994
|
-
if (len >
|
|
28995
|
-
throw new Error(`Remote file too large: ${len} bytes exceeds ${
|
|
29168
|
+
if (len > maxBytes) {
|
|
29169
|
+
throw new Error(`Remote file too large: ${len} bytes exceeds ${maxBytes} byte cap.`);
|
|
28996
29170
|
}
|
|
28997
29171
|
const buf = Buffer.from(await res.arrayBuffer());
|
|
28998
|
-
if (buf.length >
|
|
28999
|
-
throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${
|
|
29172
|
+
if (buf.length > maxBytes) {
|
|
29173
|
+
throw new Error(`Remote file too large: ${buf.length} bytes exceeds ${maxBytes} byte cap.`);
|
|
29000
29174
|
}
|
|
29001
29175
|
return buf;
|
|
29002
29176
|
} finally {
|
|
@@ -29027,6 +29201,13 @@ var EXT_MIME = {
|
|
|
29027
29201
|
".mp3": "audio/mpeg",
|
|
29028
29202
|
".m4a": "audio/mp4",
|
|
29029
29203
|
".ogg": "audio/ogg",
|
|
29204
|
+
".aac": "audio/aac",
|
|
29205
|
+
".flac": "audio/flac",
|
|
29206
|
+
".mp4": "video/mp4",
|
|
29207
|
+
".m4v": "video/x-m4v",
|
|
29208
|
+
".webm": "video/webm",
|
|
29209
|
+
".mov": "video/quicktime",
|
|
29210
|
+
".mkv": "video/x-matroska",
|
|
29030
29211
|
".zip": "application/zip"
|
|
29031
29212
|
};
|
|
29032
29213
|
function detectMime(buffer, name) {
|
|
@@ -29220,6 +29401,170 @@ async function resolveGroup(auth, target) {
|
|
|
29220
29401
|
}
|
|
29221
29402
|
return fetchConversationById(auth, matches[0].id);
|
|
29222
29403
|
}
|
|
29404
|
+
async function fetchUserById(auth, id) {
|
|
29405
|
+
try {
|
|
29406
|
+
const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(id)}`, { timeoutMs: 15000 });
|
|
29407
|
+
return prof.user?.id ? { ...prof.user, id: prof.user.id } : null;
|
|
29408
|
+
} catch (e5) {
|
|
29409
|
+
if (e5.status === 404)
|
|
29410
|
+
return null;
|
|
29411
|
+
throw e5;
|
|
29412
|
+
}
|
|
29413
|
+
}
|
|
29414
|
+
async function findDmByUserId(auth, userId) {
|
|
29415
|
+
const want = normalizeUserId(userId);
|
|
29416
|
+
const limit = 100;
|
|
29417
|
+
for (let offset = 0;offset < 1e4; offset += limit) {
|
|
29418
|
+
const data = await amikoWebFetch(auth, "/api/conversations", {
|
|
29419
|
+
query: { limit, offset },
|
|
29420
|
+
timeoutMs: 20000
|
|
29421
|
+
});
|
|
29422
|
+
const page = data.conversations ?? [];
|
|
29423
|
+
const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
|
|
29424
|
+
if (dm)
|
|
29425
|
+
return dm.id;
|
|
29426
|
+
if (page.length < limit || data.pagination?.has_more === false)
|
|
29427
|
+
return null;
|
|
29428
|
+
}
|
|
29429
|
+
return null;
|
|
29430
|
+
}
|
|
29431
|
+
function personLabel(u) {
|
|
29432
|
+
const name = u.name ?? u.id;
|
|
29433
|
+
return u.handle ? `${name} (@${u.handle})` : name;
|
|
29434
|
+
}
|
|
29435
|
+
async function resolveConversation(auth, target) {
|
|
29436
|
+
const t = target.trim();
|
|
29437
|
+
if (!t) {
|
|
29438
|
+
throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
|
|
29439
|
+
}
|
|
29440
|
+
const person = (u) => ({
|
|
29441
|
+
kind: "user",
|
|
29442
|
+
userId: u.id,
|
|
29443
|
+
label: `DM with ${personLabel(u)}`
|
|
29444
|
+
});
|
|
29445
|
+
if (t.startsWith("@")) {
|
|
29446
|
+
const handle = t.slice(1);
|
|
29447
|
+
const prof = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(handle)}`, {
|
|
29448
|
+
timeoutMs: 15000
|
|
29449
|
+
});
|
|
29450
|
+
if (!prof.user?.id)
|
|
29451
|
+
throw new Error(`No user @${handle}.`);
|
|
29452
|
+
return person({ ...prof.user, id: prof.user.id });
|
|
29453
|
+
}
|
|
29454
|
+
const idish = normalizeUserId(t);
|
|
29455
|
+
if (looksLikeConversationId(idish)) {
|
|
29456
|
+
if (idish === t) {
|
|
29457
|
+
try {
|
|
29458
|
+
const conv = await fetchConversationById(auth, t);
|
|
29459
|
+
return {
|
|
29460
|
+
kind: "conversation",
|
|
29461
|
+
id: conv.id,
|
|
29462
|
+
label: conv.conversation_type === "direct" ? `DM with ${convLabel(conv, auth.userId)}` : `group "${convLabel(conv, auth.userId)}"`,
|
|
29463
|
+
conversation: conv
|
|
29464
|
+
};
|
|
29465
|
+
} catch (e5) {
|
|
29466
|
+
const status = e5.status;
|
|
29467
|
+
if (status === 403) {
|
|
29468
|
+
throw new Error(`Conversation ${t} exists but you are not a participant.`);
|
|
29469
|
+
}
|
|
29470
|
+
if (status !== undefined && status !== 404)
|
|
29471
|
+
throw e5;
|
|
29472
|
+
}
|
|
29473
|
+
}
|
|
29474
|
+
const user = await fetchUserById(auth, idish);
|
|
29475
|
+
if (!user) {
|
|
29476
|
+
throw new Error(`"${t}" is neither a conversation nor a user id — check \`amiko chat list\` or \`amiko friends list --json\`.`);
|
|
29477
|
+
}
|
|
29478
|
+
return person(user);
|
|
29479
|
+
}
|
|
29480
|
+
const groupId = await findGroupIdByExactTitle(auth, t);
|
|
29481
|
+
if (groupId)
|
|
29482
|
+
return { kind: "conversation", id: groupId, label: `group "${t}"` };
|
|
29483
|
+
const { resolved, failures } = await resolveMembers(auth, [t]);
|
|
29484
|
+
if (failures.length || resolved.length !== 1) {
|
|
29485
|
+
const reason = failures[0]?.reason ?? "could not be resolved";
|
|
29486
|
+
throw new Error(`"${t}" ${reason}`);
|
|
29487
|
+
}
|
|
29488
|
+
const p = resolved[0];
|
|
29489
|
+
return person({ id: p.userId, name: p.name, handle: p.handle });
|
|
29490
|
+
}
|
|
29491
|
+
|
|
29492
|
+
// src/lib/mentions.ts
|
|
29493
|
+
var MENTION_ALL_TOKEN = "@[all](all:all)";
|
|
29494
|
+
var MENTION_ALL_LITERAL_SRC = String.raw`(^|[^\w@])@all\b`;
|
|
29495
|
+
function hasMentionAllToken(content) {
|
|
29496
|
+
return content.includes(MENTION_ALL_TOKEN);
|
|
29497
|
+
}
|
|
29498
|
+
function hasMentionAllLiteral(content) {
|
|
29499
|
+
return new RegExp(MENTION_ALL_LITERAL_SRC, "i").test(content);
|
|
29500
|
+
}
|
|
29501
|
+
function rewriteMentionAllLiteral(content) {
|
|
29502
|
+
return content.replace(new RegExp(MENTION_ALL_LITERAL_SRC, "gi"), `$1${MENTION_ALL_TOKEN}`);
|
|
29503
|
+
}
|
|
29504
|
+
function applyMentionAll(content) {
|
|
29505
|
+
if (hasMentionAllToken(content))
|
|
29506
|
+
return content;
|
|
29507
|
+
return content ? `${MENTION_ALL_TOKEN} ${content}` : MENTION_ALL_TOKEN;
|
|
29508
|
+
}
|
|
29509
|
+
function mentionMarkupToPlainText(content) {
|
|
29510
|
+
return content.replace(/@\[([^\]]+)\]\(([^:()\s]+):(user|agent|all)\)/g, "@$1");
|
|
29511
|
+
}
|
|
29512
|
+
function canMentionAll(conv, selfUserId) {
|
|
29513
|
+
if (conv.conversation_type !== "group" || !selfUserId)
|
|
29514
|
+
return false;
|
|
29515
|
+
const me = normalizeUserId(selfUserId);
|
|
29516
|
+
const self = (conv.participants ?? []).find((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === me && !p.left_at);
|
|
29517
|
+
if (!self)
|
|
29518
|
+
return false;
|
|
29519
|
+
return self.role === "admin" || self.role === "owner" || conv.allow_everyone_mention_all === true;
|
|
29520
|
+
}
|
|
29521
|
+
|
|
29522
|
+
// src/lib/reactions.ts
|
|
29523
|
+
var EMOJI_MAX = 32;
|
|
29524
|
+
function validateEmoji(raw) {
|
|
29525
|
+
const emoji3 = (raw ?? "").trim();
|
|
29526
|
+
if (!emoji3)
|
|
29527
|
+
throw new Error("No emoji given. Pass the emoji to react with, e.g. ❤️.");
|
|
29528
|
+
if (emoji3.length > EMOJI_MAX) {
|
|
29529
|
+
throw new Error(`Emoji is too long (max ${EMOJI_MAX} characters).`);
|
|
29530
|
+
}
|
|
29531
|
+
return emoji3;
|
|
29532
|
+
}
|
|
29533
|
+
async function fetchMessage(auth, messageId) {
|
|
29534
|
+
const data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}`, { timeoutMs: 20000 });
|
|
29535
|
+
if (!data.message?.id) {
|
|
29536
|
+
throw new Error(`Message ${messageId} not found.`);
|
|
29537
|
+
}
|
|
29538
|
+
return { ...data.message, reactions: data.message.reactions ?? [] };
|
|
29539
|
+
}
|
|
29540
|
+
function ownReaction(reactions, userId) {
|
|
29541
|
+
if (!userId)
|
|
29542
|
+
return null;
|
|
29543
|
+
const me = normalizeUserId(userId);
|
|
29544
|
+
const mine = reactions.find((r) => normalizeUserId(r.user_id) === me);
|
|
29545
|
+
return mine?.emoji ?? null;
|
|
29546
|
+
}
|
|
29547
|
+
function previewText(msg, max = 60) {
|
|
29548
|
+
const content = (msg.content ?? "").trim();
|
|
29549
|
+
if (content) {
|
|
29550
|
+
return content.length > max ? `${content.slice(0, max)}…` : content;
|
|
29551
|
+
}
|
|
29552
|
+
if (msg.message_type === "voice")
|
|
29553
|
+
return "[voice message]";
|
|
29554
|
+
if (msg.metadata?.images?.length)
|
|
29555
|
+
return "[image]";
|
|
29556
|
+
return `[${msg.message_type || "message"}]`;
|
|
29557
|
+
}
|
|
29558
|
+
function groupReactions(reactions) {
|
|
29559
|
+
const counts = new Map;
|
|
29560
|
+
for (const r of reactions) {
|
|
29561
|
+
counts.set(r.emoji, (counts.get(r.emoji) ?? 0) + 1);
|
|
29562
|
+
}
|
|
29563
|
+
return Array.from(counts.entries());
|
|
29564
|
+
}
|
|
29565
|
+
function summarizeReactions(reactions) {
|
|
29566
|
+
return groupReactions(reactions).map(([emoji3, count]) => `${emoji3}×${count}`).join(" ");
|
|
29567
|
+
}
|
|
29223
29568
|
|
|
29224
29569
|
// src/commands/chat-group.ts
|
|
29225
29570
|
var collect = (v, prev) => [...prev, v];
|
|
@@ -29359,7 +29704,7 @@ function registerChatGroupCommand(group) {
|
|
|
29359
29704
|
g.id,
|
|
29360
29705
|
String(g.participants?.length ?? "-"),
|
|
29361
29706
|
g.unread_count ? String(g.unread_count) : "-",
|
|
29362
|
-
g.last_message?.content
|
|
29707
|
+
g.last_message?.content ? mentionMarkupToPlainText(g.last_message.content).slice(0, 40) : "-"
|
|
29363
29708
|
])
|
|
29364
29709
|
];
|
|
29365
29710
|
console.log(table(rows));
|
|
@@ -29376,6 +29721,7 @@ function registerChatGroupCommand(group) {
|
|
|
29376
29721
|
console.log(heading(conv.title ?? "(untitled group)"));
|
|
29377
29722
|
console.log(label("ID", conv.id));
|
|
29378
29723
|
console.log(label("Members", String(active.length)));
|
|
29724
|
+
console.log(label("@all mentions", conv.allow_everyone_mention_all ? "everyone" : "admins only"));
|
|
29379
29725
|
console.log("");
|
|
29380
29726
|
const rows = [
|
|
29381
29727
|
[dim("NAME"), dim("HANDLE"), dim("USER ID"), dim("TYPE"), dim("ROLE")],
|
|
@@ -29581,111 +29927,461 @@ function registerChatGroupCommand(group) {
|
|
|
29581
29927
|
}
|
|
29582
29928
|
console.log(success(`${memberLabel(member)} is now an admin.`));
|
|
29583
29929
|
});
|
|
29584
|
-
|
|
29585
|
-
|
|
29586
|
-
|
|
29587
|
-
|
|
29588
|
-
|
|
29589
|
-
|
|
29590
|
-
|
|
29591
|
-
|
|
29592
|
-
|
|
29593
|
-
|
|
29594
|
-
|
|
29595
|
-
|
|
29930
|
+
group.command("mention-all <group> <state>").description("Allow everyone (on) or only admins (off, the default) to use @all in a group (admins only)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, stateArg, opts) => {
|
|
29931
|
+
const state = stateArg.trim().toLowerCase();
|
|
29932
|
+
if (state !== "on" && state !== "off") {
|
|
29933
|
+
console.error(error(`Pass "on" or "off" (got "${stateArg}"): on lets every member use @all, off restricts it to admins.`));
|
|
29934
|
+
process.exit(1);
|
|
29935
|
+
}
|
|
29936
|
+
const enable = state === "on";
|
|
29937
|
+
const auth = authOrExit();
|
|
29938
|
+
const conv = await resolveGroupOrExit(auth, target);
|
|
29939
|
+
await confirmDestructive({
|
|
29940
|
+
action: enable ? `Allow EVERYONE in "${conv.title ?? conv.id}" to use @all (notifies every member)` : `Restrict @all in "${conv.title ?? conv.id}" to group admins only`,
|
|
29941
|
+
yes: opts.yes,
|
|
29942
|
+
commandExample: `amiko chat group mention-all ${JSON.stringify(target)} ${state}`
|
|
29943
|
+
});
|
|
29944
|
+
let data;
|
|
29945
|
+
try {
|
|
29946
|
+
data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(conv.id)}/mention-all-permission`, {
|
|
29947
|
+
method: "PATCH",
|
|
29948
|
+
body: { allow_everyone_mention_all: enable },
|
|
29949
|
+
timeoutMs: 20000
|
|
29950
|
+
});
|
|
29951
|
+
} catch (e5) {
|
|
29952
|
+
if (statusOf2(e5) === 403) {
|
|
29953
|
+
console.error(error(`Changing the @all setting failed. ${ADMIN_TIP}`));
|
|
29954
|
+
process.exit(1);
|
|
29955
|
+
}
|
|
29956
|
+
exitWithError(e5, "Failed to update the @all setting");
|
|
29957
|
+
}
|
|
29958
|
+
if (opts.raw) {
|
|
29959
|
+
console.log(JSON.stringify(data, null, 2));
|
|
29960
|
+
return;
|
|
29961
|
+
}
|
|
29962
|
+
const now = data.conversation?.allow_everyone_mention_all ?? enable;
|
|
29963
|
+
console.log(success(`@all in "${conv.title ?? conv.id}": ${now ? "everyone can use it" : "admins only"}`) + dim(` (${conv.id})`));
|
|
29596
29964
|
});
|
|
29597
|
-
const id = data.conversation?.id;
|
|
29598
|
-
if (!id)
|
|
29599
|
-
throw new Error("Could not open a conversation with that user.");
|
|
29600
|
-
return id;
|
|
29601
29965
|
}
|
|
29602
|
-
|
|
29966
|
+
|
|
29967
|
+
// src/lib/gifs.ts
|
|
29968
|
+
function parseUrl(url2) {
|
|
29603
29969
|
try {
|
|
29604
|
-
|
|
29605
|
-
|
|
29606
|
-
|
|
29607
|
-
if (e5.status === 404)
|
|
29608
|
-
return null;
|
|
29609
|
-
throw e5;
|
|
29970
|
+
return new URL(url2);
|
|
29971
|
+
} catch {
|
|
29972
|
+
return null;
|
|
29610
29973
|
}
|
|
29611
29974
|
}
|
|
29612
|
-
|
|
29613
|
-
const
|
|
29614
|
-
|
|
29615
|
-
|
|
29616
|
-
|
|
29617
|
-
query: { limit, offset },
|
|
29618
|
-
timeoutMs: 20000
|
|
29619
|
-
});
|
|
29620
|
-
const page = data.conversations ?? [];
|
|
29621
|
-
const dm = page.find((c) => c.conversation_type === "direct" && (c.participants ?? []).some((p) => p.participant_type === "user" && normalizeUserId(p.participant_id) === want));
|
|
29622
|
-
if (dm)
|
|
29623
|
-
return dm.id;
|
|
29624
|
-
if (page.length < limit || data.pagination?.has_more === false)
|
|
29625
|
-
return null;
|
|
29626
|
-
}
|
|
29627
|
-
return null;
|
|
29975
|
+
function isKlipyMediaUrl(url2) {
|
|
29976
|
+
const parsed = parseUrl(url2);
|
|
29977
|
+
if (!parsed)
|
|
29978
|
+
return false;
|
|
29979
|
+
return parsed.hostname === "klipy.com" || parsed.hostname.endsWith(".klipy.com");
|
|
29628
29980
|
}
|
|
29629
|
-
|
|
29630
|
-
|
|
29631
|
-
|
|
29981
|
+
var KLIPY_VIDEO_EXTENSION_RE = /\.(?:mp4|webm)(?:[?#].*)?$/i;
|
|
29982
|
+
function isKlipyVideoMediaUrl(url2) {
|
|
29983
|
+
if (!isKlipyMediaUrl(url2))
|
|
29984
|
+
return false;
|
|
29985
|
+
const parsed = parseUrl(url2);
|
|
29986
|
+
if (!parsed)
|
|
29987
|
+
return false;
|
|
29988
|
+
return KLIPY_VIDEO_EXTENSION_RE.test(parsed.pathname);
|
|
29632
29989
|
}
|
|
29633
|
-
async function
|
|
29634
|
-
|
|
29635
|
-
|
|
29636
|
-
throw new Error("No target given. Pass a conversation id, a user id, an @handle, or a name.");
|
|
29637
|
-
}
|
|
29638
|
-
const person = (u) => ({
|
|
29639
|
-
kind: "user",
|
|
29640
|
-
userId: u.id,
|
|
29641
|
-
label: `DM with ${personLabel(u)}`
|
|
29990
|
+
async function searchGifs(auth, q, page = 1) {
|
|
29991
|
+
return amikoWebFetch(auth, "/api/gifs", {
|
|
29992
|
+
query: { q, page }
|
|
29642
29993
|
});
|
|
29643
|
-
|
|
29644
|
-
|
|
29645
|
-
|
|
29646
|
-
|
|
29647
|
-
|
|
29648
|
-
|
|
29649
|
-
|
|
29650
|
-
|
|
29994
|
+
}
|
|
29995
|
+
function urlBasename(url2) {
|
|
29996
|
+
const last = parseUrl(url2)?.pathname.split("/").pop();
|
|
29997
|
+
if (!last)
|
|
29998
|
+
return "GIF";
|
|
29999
|
+
try {
|
|
30000
|
+
return decodeURIComponent(last);
|
|
30001
|
+
} catch {
|
|
30002
|
+
return last;
|
|
29651
30003
|
}
|
|
29652
|
-
|
|
29653
|
-
|
|
29654
|
-
|
|
29655
|
-
|
|
29656
|
-
|
|
29657
|
-
|
|
29658
|
-
|
|
29659
|
-
|
|
29660
|
-
|
|
29661
|
-
|
|
29662
|
-
|
|
29663
|
-
|
|
29664
|
-
|
|
29665
|
-
|
|
30004
|
+
}
|
|
30005
|
+
function gifToMetadata(gif) {
|
|
30006
|
+
return {
|
|
30007
|
+
url: gif.url,
|
|
30008
|
+
name: gif.title || urlBasename(gif.url),
|
|
30009
|
+
contentType: isKlipyVideoMediaUrl(gif.url) ? "video/mp4" : "image/gif",
|
|
30010
|
+
kind: "gif",
|
|
30011
|
+
...gif.width && gif.height ? { width: gif.width, height: gif.height } : {}
|
|
30012
|
+
};
|
|
30013
|
+
}
|
|
30014
|
+
|
|
30015
|
+
// src/commands/chat-gifs.ts
|
|
30016
|
+
var DEFAULT_LIMIT = 10;
|
|
30017
|
+
var MAX_TITLE = 40;
|
|
30018
|
+
function clampLimit(raw) {
|
|
30019
|
+
const n = Number.parseInt(raw, 10);
|
|
30020
|
+
if (!Number.isFinite(n))
|
|
30021
|
+
return DEFAULT_LIMIT;
|
|
30022
|
+
return Math.min(24, Math.max(1, n));
|
|
30023
|
+
}
|
|
30024
|
+
function truncate(text, max) {
|
|
30025
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
30026
|
+
}
|
|
30027
|
+
function registerChatGifsCommand(chat) {
|
|
30028
|
+
chat.command("gifs [query]").description("Search Klipy GIFs (trending when no query) — pass a result URL to `amiko chat send --gif`").option("--page <n>", "Result page (24 per page)", "1").option("--limit <n>", "Max results shown (1-24)", String(DEFAULT_LIMIT)).option("--raw", "Output raw JSON").action(async (query, opts) => {
|
|
30029
|
+
const auth = authOrExit();
|
|
30030
|
+
const q = query?.trim() ?? "";
|
|
30031
|
+
const page = Math.max(1, Number.parseInt(opts.page, 10) || 1);
|
|
30032
|
+
let data;
|
|
30033
|
+
try {
|
|
30034
|
+
data = await searchGifs(auth, q, page);
|
|
30035
|
+
} catch (e5) {
|
|
30036
|
+
console.error(error(e5 instanceof Error ? e5.message : "GIF search failed"));
|
|
30037
|
+
process.exit(1);
|
|
30038
|
+
}
|
|
30039
|
+
renderOutput(data, (d) => {
|
|
30040
|
+
const items = (d.items ?? []).slice(0, clampLimit(opts.limit));
|
|
30041
|
+
if (items.length === 0) {
|
|
30042
|
+
console.log(dim(q ? `No GIFs found for "${q}".` : "No trending GIFs."));
|
|
30043
|
+
console.log(dim("Powered by KLIPY"));
|
|
30044
|
+
return;
|
|
30045
|
+
}
|
|
30046
|
+
console.log(heading(q ? `GIFs matching "${q}" (page ${d.page})` : `Trending GIFs (page ${d.page})`));
|
|
30047
|
+
const rows = [
|
|
30048
|
+
[dim("TITLE"), dim("SIZE"), dim("URL")],
|
|
30049
|
+
...items.map((gif) => [
|
|
30050
|
+
truncate(gif.title || "GIF", MAX_TITLE),
|
|
30051
|
+
gif.width && gif.height ? `${gif.width}x${gif.height}` : "-",
|
|
30052
|
+
gif.gifUrl
|
|
30053
|
+
])
|
|
30054
|
+
];
|
|
30055
|
+
console.log(table(rows));
|
|
30056
|
+
console.log(dim(`
|
|
30057
|
+
Send one: amiko chat send <target> --gif <url>${d.hasNext ? ` (more: --page ${d.page + 1})` : ""}`));
|
|
30058
|
+
console.log(dim("Powered by KLIPY"));
|
|
30059
|
+
}, { json: opts.raw });
|
|
30060
|
+
});
|
|
30061
|
+
}
|
|
30062
|
+
|
|
30063
|
+
// src/commands/chat-lists.ts
|
|
30064
|
+
var collect2 = (v, prev) => [...prev, v];
|
|
30065
|
+
function exitWithError2(e5, fallback2) {
|
|
30066
|
+
console.error(error(e5 instanceof Error ? e5.message : fallback2));
|
|
30067
|
+
process.exit(1);
|
|
30068
|
+
}
|
|
30069
|
+
function conversationFlags(tokens) {
|
|
30070
|
+
return tokens.map((t) => ` --conversation ${JSON.stringify(t)}`).join("");
|
|
30071
|
+
}
|
|
30072
|
+
function countNoun(n) {
|
|
30073
|
+
return `${n} conversation${n === 1 ? "" : "s"}`;
|
|
30074
|
+
}
|
|
30075
|
+
async function fetchChatLists(auth) {
|
|
30076
|
+
const data = await amikoWebFetch(auth, "/api/chat-lists", { timeoutMs: 20000 });
|
|
30077
|
+
return data.chatLists ?? [];
|
|
30078
|
+
}
|
|
30079
|
+
async function resolveList(auth, target) {
|
|
30080
|
+
const t = target.trim();
|
|
30081
|
+
if (!t) {
|
|
30082
|
+
throw new Error("No list given. Run `amiko chat lists` and pass a list id or name.");
|
|
30083
|
+
}
|
|
30084
|
+
const all = await fetchChatLists(auth);
|
|
30085
|
+
if (looksLikeConversationId(t)) {
|
|
30086
|
+
const byId = all.find((l) => l.id === t);
|
|
30087
|
+
if (byId)
|
|
30088
|
+
return byId;
|
|
30089
|
+
}
|
|
30090
|
+
const lower = t.toLowerCase();
|
|
30091
|
+
const exact = all.filter((l) => l.name.toLowerCase() === lower);
|
|
30092
|
+
const matches = exact.length ? exact : all.filter((l) => l.name.toLowerCase().includes(lower));
|
|
30093
|
+
if (matches.length === 0) {
|
|
30094
|
+
throw new Error(`No chat list matching "${t}". Run \`amiko chat lists\` to see them.`);
|
|
30095
|
+
}
|
|
30096
|
+
if (matches.length > 1) {
|
|
30097
|
+
const names = matches.map((l) => `"${l.name}" (${l.id})`).join(", ");
|
|
30098
|
+
throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the list's id.`);
|
|
30099
|
+
}
|
|
30100
|
+
return matches[0];
|
|
30101
|
+
}
|
|
30102
|
+
async function resolveListOrExit(auth, target) {
|
|
30103
|
+
try {
|
|
30104
|
+
return await resolveList(auth, target);
|
|
30105
|
+
} catch (e5) {
|
|
30106
|
+
exitWithError2(e5, "Could not resolve the chat list");
|
|
30107
|
+
}
|
|
30108
|
+
}
|
|
30109
|
+
async function resolveEntriesOrExit(auth, tokens) {
|
|
30110
|
+
if (tokens.length === 0) {
|
|
30111
|
+
console.error(error("At least one --conversation is required."));
|
|
30112
|
+
process.exit(1);
|
|
30113
|
+
}
|
|
30114
|
+
const entries = [];
|
|
30115
|
+
const failures = [];
|
|
30116
|
+
const seen = new Set;
|
|
30117
|
+
for (const raw of tokens) {
|
|
30118
|
+
try {
|
|
30119
|
+
const resolved = await resolveConversation(auth, raw);
|
|
30120
|
+
let entry;
|
|
30121
|
+
if (resolved.kind === "conversation") {
|
|
30122
|
+
entry = { id: resolved.id, label: resolved.label };
|
|
30123
|
+
} else {
|
|
30124
|
+
const existing = await findDmByUserId(auth, resolved.userId);
|
|
30125
|
+
if (!existing) {
|
|
30126
|
+
throw new Error(`no ${resolved.label} exists yet — a chat list can only hold existing conversations.`);
|
|
29666
30127
|
}
|
|
29667
|
-
|
|
29668
|
-
throw e5;
|
|
30128
|
+
entry = { id: existing, label: resolved.label };
|
|
29669
30129
|
}
|
|
30130
|
+
if (seen.has(entry.id))
|
|
30131
|
+
continue;
|
|
30132
|
+
seen.add(entry.id);
|
|
30133
|
+
entries.push(entry);
|
|
30134
|
+
} catch (e5) {
|
|
30135
|
+
failures.push({
|
|
30136
|
+
token: raw,
|
|
30137
|
+
reason: e5 instanceof Error ? e5.message : "could not be resolved"
|
|
30138
|
+
});
|
|
29670
30139
|
}
|
|
29671
|
-
|
|
29672
|
-
|
|
29673
|
-
|
|
30140
|
+
}
|
|
30141
|
+
if (failures.length > 0) {
|
|
30142
|
+
console.error(error("Could not resolve every conversation — nothing was changed."));
|
|
30143
|
+
for (const entry of entries) {
|
|
30144
|
+
console.error(dim(` resolved: ${entry.label} → ${entry.id}`));
|
|
29674
30145
|
}
|
|
29675
|
-
|
|
30146
|
+
for (const f of failures) {
|
|
30147
|
+
console.error(error(` ${JSON.stringify(f.token)}: ${f.reason}`));
|
|
30148
|
+
}
|
|
30149
|
+
process.exit(1);
|
|
29676
30150
|
}
|
|
29677
|
-
|
|
29678
|
-
|
|
29679
|
-
|
|
29680
|
-
const
|
|
29681
|
-
|
|
29682
|
-
const
|
|
29683
|
-
|
|
30151
|
+
return entries;
|
|
30152
|
+
}
|
|
30153
|
+
async function conversationLabels(auth) {
|
|
30154
|
+
const labels = new Map;
|
|
30155
|
+
try {
|
|
30156
|
+
const data = await amikoWebFetch(auth, "/api/conversations", { query: { limit: 100, archived: "all" }, timeoutMs: 20000 });
|
|
30157
|
+
for (const c of data.conversations ?? []) {
|
|
30158
|
+
labels.set(c.id, convLabel(c, auth.userId));
|
|
30159
|
+
}
|
|
30160
|
+
} catch {}
|
|
30161
|
+
return labels;
|
|
30162
|
+
}
|
|
30163
|
+
function listNotFound(e5) {
|
|
30164
|
+
return e5.status === 404;
|
|
30165
|
+
}
|
|
30166
|
+
function registerChatListsCommand(lists) {
|
|
30167
|
+
lists.option("--raw", "Output raw JSON").action(async (opts) => {
|
|
30168
|
+
const auth = authOrExit();
|
|
30169
|
+
let all;
|
|
30170
|
+
try {
|
|
30171
|
+
all = await fetchChatLists(auth);
|
|
30172
|
+
} catch (e5) {
|
|
30173
|
+
exitWithError2(e5, "Failed to load chat lists");
|
|
30174
|
+
}
|
|
30175
|
+
if (opts.raw) {
|
|
30176
|
+
console.log(JSON.stringify({ chatLists: all }, null, 2));
|
|
30177
|
+
return;
|
|
30178
|
+
}
|
|
30179
|
+
if (all.length === 0) {
|
|
30180
|
+
console.log(dim("No chat lists."));
|
|
30181
|
+
console.log(dim("Create one with `amiko chat lists create <name> --conversation <who>`."));
|
|
30182
|
+
return;
|
|
30183
|
+
}
|
|
30184
|
+
const labels = await conversationLabels(auth);
|
|
30185
|
+
console.log(heading(`Chat lists (${all.length})
|
|
30186
|
+
`));
|
|
30187
|
+
for (const l of all) {
|
|
30188
|
+
const ids = l.conversation_ids ?? [];
|
|
30189
|
+
console.log(label(l.name, dim(`${l.id} · ${countNoun(ids.length)}`)));
|
|
30190
|
+
for (const id of ids) {
|
|
30191
|
+
const name = labels.get(id);
|
|
30192
|
+
console.log(dim(name ? ` - ${name} (${id})` : ` - ${id}`));
|
|
30193
|
+
}
|
|
30194
|
+
}
|
|
30195
|
+
});
|
|
30196
|
+
lists.command("create <name>").description("Create a chat list, optionally with initial conversations").option("--conversation <target>", "Conversation to include (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (name, opts) => {
|
|
30197
|
+
const auth = authOrExit();
|
|
30198
|
+
const entries = opts.conversation.length > 0 ? await resolveEntriesOrExit(auth, opts.conversation) : [];
|
|
30199
|
+
await confirmDestructive({
|
|
30200
|
+
action: `Create chat list "${name}"${entries.length ? ` with ${entries.map((e5) => e5.label).join(", ")}` : " (empty)"}`,
|
|
30201
|
+
detail: "Chat lists are private folders — only you see them, in every Amiko app.",
|
|
30202
|
+
yes: opts.yes,
|
|
30203
|
+
commandExample: `amiko chat lists create ${JSON.stringify(name)}${conversationFlags(opts.conversation)}`
|
|
30204
|
+
});
|
|
30205
|
+
let data;
|
|
30206
|
+
try {
|
|
30207
|
+
data = await amikoWebFetch(auth, "/api/chat-lists", {
|
|
30208
|
+
method: "POST",
|
|
30209
|
+
body: { name, conversation_ids: entries.map((e5) => e5.id) },
|
|
30210
|
+
timeoutMs: 20000
|
|
30211
|
+
});
|
|
30212
|
+
} catch (e5) {
|
|
30213
|
+
exitWithError2(e5, "Failed to create the chat list");
|
|
30214
|
+
}
|
|
30215
|
+
if (opts.raw) {
|
|
30216
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30217
|
+
return;
|
|
30218
|
+
}
|
|
30219
|
+
console.log(success(`Chat list created: ${name}`));
|
|
30220
|
+
if (data.chatList?.id)
|
|
30221
|
+
console.log(label("ID", data.chatList.id));
|
|
30222
|
+
if (entries.length) {
|
|
30223
|
+
console.log(label("Conversations", entries.map((e5) => e5.label).join(", ")));
|
|
30224
|
+
}
|
|
30225
|
+
});
|
|
30226
|
+
lists.command("rename <list> <newName>").description("Rename a chat list (list id or name)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, newName, opts) => {
|
|
30227
|
+
const auth = authOrExit();
|
|
30228
|
+
const list = await resolveListOrExit(auth, target);
|
|
30229
|
+
await confirmDestructive({
|
|
30230
|
+
action: `Rename chat list "${list.name}" to "${newName}"`,
|
|
30231
|
+
yes: opts.yes,
|
|
30232
|
+
commandExample: `amiko chat lists rename ${JSON.stringify(target)} ${JSON.stringify(newName)}`
|
|
30233
|
+
});
|
|
30234
|
+
let data;
|
|
30235
|
+
try {
|
|
30236
|
+
data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, { method: "PATCH", body: { name: newName }, timeoutMs: 20000 });
|
|
30237
|
+
} catch (e5) {
|
|
30238
|
+
exitWithError2(e5, "Rename failed");
|
|
30239
|
+
}
|
|
30240
|
+
if (opts.raw) {
|
|
30241
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30242
|
+
return;
|
|
30243
|
+
}
|
|
30244
|
+
console.log(success(`Chat list renamed to: ${newName}`) + dim(` (${list.id})`));
|
|
30245
|
+
});
|
|
30246
|
+
lists.command("add <list>").description("Add conversations to a chat list (list id or name)").option("--conversation <target>", "Conversation to add (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
|
|
30247
|
+
const auth = authOrExit();
|
|
30248
|
+
const entries = await resolveEntriesOrExit(auth, opts.conversation);
|
|
30249
|
+
const list = await resolveListOrExit(auth, target);
|
|
30250
|
+
const current = list.conversation_ids ?? [];
|
|
30251
|
+
const currentSet = new Set(current);
|
|
30252
|
+
const fresh = entries.filter((e5) => !currentSet.has(e5.id));
|
|
30253
|
+
const already = entries.filter((e5) => currentSet.has(e5.id));
|
|
30254
|
+
if (fresh.length === 0) {
|
|
30255
|
+
console.log(dim(`Already in "${list.name}" — nothing to add.`));
|
|
30256
|
+
return;
|
|
30257
|
+
}
|
|
30258
|
+
await confirmDestructive({
|
|
30259
|
+
action: `Add ${fresh.map((e5) => e5.label).join(", ")} to chat list "${list.name}"`,
|
|
30260
|
+
yes: opts.yes,
|
|
30261
|
+
commandExample: `amiko chat lists add ${JSON.stringify(target)}${conversationFlags(opts.conversation)}`
|
|
30262
|
+
});
|
|
30263
|
+
let data;
|
|
30264
|
+
try {
|
|
30265
|
+
data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, {
|
|
30266
|
+
method: "PATCH",
|
|
30267
|
+
body: {
|
|
30268
|
+
conversation_ids: [...current, ...fresh.map((e5) => e5.id)]
|
|
30269
|
+
},
|
|
30270
|
+
timeoutMs: 20000
|
|
30271
|
+
});
|
|
30272
|
+
} catch (e5) {
|
|
30273
|
+
if (listNotFound(e5)) {
|
|
30274
|
+
exitWithError2(null, `Chat list "${list.name}" no longer exists.`);
|
|
30275
|
+
}
|
|
30276
|
+
exitWithError2(e5, "Failed to update the chat list");
|
|
30277
|
+
}
|
|
30278
|
+
if (opts.raw) {
|
|
30279
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30280
|
+
return;
|
|
30281
|
+
}
|
|
30282
|
+
console.log(success(`Added ${fresh.map((e5) => e5.label).join(", ")} to "${list.name}"`) + dim(` (now ${countNoun(current.length + fresh.length)})`));
|
|
30283
|
+
for (const e5 of already) {
|
|
30284
|
+
console.log(dim(` ${e5.label} was already in the list.`));
|
|
30285
|
+
}
|
|
30286
|
+
});
|
|
30287
|
+
lists.command("remove <list>").description("Remove conversations from a chat list (the conversations themselves are untouched)").option("--conversation <target>", "Conversation to remove (conversation id, user id, @handle, group title, or name); repeat per conversation", collect2, []).option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
|
|
30288
|
+
const auth = authOrExit();
|
|
30289
|
+
const entries = await resolveEntriesOrExit(auth, opts.conversation);
|
|
30290
|
+
const list = await resolveListOrExit(auth, target);
|
|
30291
|
+
const current = list.conversation_ids ?? [];
|
|
30292
|
+
const currentSet = new Set(current);
|
|
30293
|
+
const inList = entries.filter((e5) => currentSet.has(e5.id));
|
|
30294
|
+
const notInList = entries.filter((e5) => !currentSet.has(e5.id));
|
|
30295
|
+
if (inList.length === 0) {
|
|
30296
|
+
console.log(dim(`None of those are in "${list.name}".`));
|
|
30297
|
+
return;
|
|
30298
|
+
}
|
|
30299
|
+
await confirmDestructive({
|
|
30300
|
+
action: `Remove ${inList.map((e5) => e5.label).join(", ")} from chat list "${list.name}"`,
|
|
30301
|
+
yes: opts.yes,
|
|
30302
|
+
commandExample: `amiko chat lists remove ${JSON.stringify(target)}${conversationFlags(opts.conversation)}`
|
|
30303
|
+
});
|
|
30304
|
+
const removeIds = new Set(inList.map((e5) => e5.id));
|
|
30305
|
+
let data;
|
|
30306
|
+
try {
|
|
30307
|
+
data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, {
|
|
30308
|
+
method: "PATCH",
|
|
30309
|
+
body: {
|
|
30310
|
+
conversation_ids: current.filter((id) => !removeIds.has(id))
|
|
30311
|
+
},
|
|
30312
|
+
timeoutMs: 20000
|
|
30313
|
+
});
|
|
30314
|
+
} catch (e5) {
|
|
30315
|
+
if (listNotFound(e5)) {
|
|
30316
|
+
exitWithError2(null, `Chat list "${list.name}" no longer exists.`);
|
|
30317
|
+
}
|
|
30318
|
+
exitWithError2(e5, "Failed to update the chat list");
|
|
30319
|
+
}
|
|
30320
|
+
if (opts.raw) {
|
|
30321
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30322
|
+
return;
|
|
30323
|
+
}
|
|
30324
|
+
console.log(success(`Removed ${inList.map((e5) => e5.label).join(", ")} from "${list.name}"`) + dim(` (now ${countNoun(current.length - inList.length)})`));
|
|
30325
|
+
for (const e5 of notInList) {
|
|
30326
|
+
console.log(dim(` ${e5.label} was not in the list.`));
|
|
30327
|
+
}
|
|
30328
|
+
});
|
|
30329
|
+
lists.command("delete <list>").description("Delete a chat list (the conversations in it are untouched)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, opts) => {
|
|
30330
|
+
const auth = authOrExit();
|
|
30331
|
+
const list = await resolveListOrExit(auth, target);
|
|
30332
|
+
const count = (list.conversation_ids ?? []).length;
|
|
30333
|
+
await confirmDestructive({
|
|
30334
|
+
action: `Delete chat list "${list.name}" (${countNoun(count)})`,
|
|
30335
|
+
detail: "The list's name and membership are gone for good; the conversations themselves are not affected.",
|
|
30336
|
+
yes: opts.yes,
|
|
30337
|
+
commandExample: `amiko chat lists delete ${JSON.stringify(target)}`
|
|
30338
|
+
});
|
|
30339
|
+
let data;
|
|
30340
|
+
try {
|
|
30341
|
+
data = await amikoWebFetch(auth, `/api/chat-lists/${encodeURIComponent(list.id)}`, { method: "DELETE", timeoutMs: 20000 });
|
|
30342
|
+
} catch (e5) {
|
|
30343
|
+
exitWithError2(e5, "Failed to delete the chat list");
|
|
30344
|
+
}
|
|
30345
|
+
if (opts.raw) {
|
|
30346
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30347
|
+
return;
|
|
30348
|
+
}
|
|
30349
|
+
console.log(success(`Chat list deleted: ${list.name}`));
|
|
30350
|
+
});
|
|
30351
|
+
}
|
|
30352
|
+
|
|
30353
|
+
// src/commands/chat.ts
|
|
30354
|
+
var PIN_LIMIT = 20;
|
|
30355
|
+
function pinErrorMessage(e5, fallback2) {
|
|
30356
|
+
const status = e5.status;
|
|
30357
|
+
if (status === 403) {
|
|
30358
|
+
return "Only group admins can pin or unpin messages in a group — check roles with `amiko chat group info`. In a DM either side can pin.";
|
|
29684
30359
|
}
|
|
29685
|
-
|
|
29686
|
-
|
|
30360
|
+
if (status === 409) {
|
|
30361
|
+
return `Pin limit reached (${PIN_LIMIT}) — unpin one first (see them with \`amiko chat pinned\`).`;
|
|
30362
|
+
}
|
|
30363
|
+
if (status === 400) {
|
|
30364
|
+
return "This message can't be pinned (system and in-flight messages aren't pinnable).";
|
|
30365
|
+
}
|
|
30366
|
+
return e5 instanceof Error ? e5.message : fallback2;
|
|
30367
|
+
}
|
|
30368
|
+
async function findOrCreateDm(auth, userId) {
|
|
30369
|
+
const data = await amikoWebFetch(auth, "/api/conversations", {
|
|
30370
|
+
method: "POST",
|
|
30371
|
+
body: {
|
|
30372
|
+
participant_ids: [userId],
|
|
30373
|
+
participant_types: ["user"],
|
|
30374
|
+
conversation_type: "direct"
|
|
30375
|
+
},
|
|
30376
|
+
timeoutMs: 20000
|
|
30377
|
+
});
|
|
30378
|
+
const id = data.conversation?.id;
|
|
30379
|
+
if (!id)
|
|
30380
|
+
throw new Error("Could not open a conversation with that user.");
|
|
30381
|
+
return id;
|
|
29687
30382
|
}
|
|
29688
30383
|
var HTTPS_RE = /^https:\/\//i;
|
|
30384
|
+
var URL_SHAPED_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
29689
30385
|
var CHAT_IMAGE_MIME = new Set([
|
|
29690
30386
|
"image/jpeg",
|
|
29691
30387
|
"image/jpg",
|
|
@@ -29732,7 +30428,7 @@ async function uploadChatVoice(auth, convId, path2) {
|
|
|
29732
30428
|
await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/audio/upload`, { method: "POST", multipart: form, timeoutMs: 120000 });
|
|
29733
30429
|
}
|
|
29734
30430
|
function registerChatCommand(chat) {
|
|
29735
|
-
chat.command("list").description("List your conversations (DMs and group chats)").option("--limit <n>", "Max conversations", "30").option("--archived", "Include archived conversations").option("--raw", "Output raw JSON").action(async (opts) => {
|
|
30431
|
+
chat.command("list").description("List your conversations (DMs and group chats)").option("--limit <n>", "Max conversations", "30").option("--archived", "Include archived conversations").option("--mentions", "Only conversations with an unread @mention of you, a permitted @all, or a reply to one of your messages").option("--raw", "Output raw JSON").action(async (opts) => {
|
|
29736
30432
|
const auth = authOrExit();
|
|
29737
30433
|
let data;
|
|
29738
30434
|
try {
|
|
@@ -29747,24 +30443,26 @@ function registerChatCommand(chat) {
|
|
|
29747
30443
|
console.error(error(e5 instanceof Error ? e5.message : "Failed to list conversations"));
|
|
29748
30444
|
process.exit(1);
|
|
29749
30445
|
}
|
|
30446
|
+
const mentionsMe = (c) => !!c.unread_count && !!c.has_unread_mention;
|
|
30447
|
+
const convs = opts.mentions ? (data.conversations ?? []).filter(mentionsMe) : data.conversations ?? [];
|
|
29750
30448
|
if (opts.raw) {
|
|
29751
|
-
console.log(JSON.stringify(data, null, 2));
|
|
30449
|
+
console.log(JSON.stringify(opts.mentions ? { ...data, conversations: convs } : data, null, 2));
|
|
29752
30450
|
return;
|
|
29753
30451
|
}
|
|
29754
|
-
const convs = data.conversations ?? [];
|
|
29755
30452
|
if (convs.length === 0) {
|
|
29756
|
-
console.log(dim("No conversations."));
|
|
30453
|
+
console.log(dim(opts.mentions ? "No conversations with unread mentions or replies to you." : "No conversations."));
|
|
29757
30454
|
return;
|
|
29758
30455
|
}
|
|
29759
30456
|
console.log(heading(`Conversations (${convs.length})
|
|
29760
30457
|
`));
|
|
29761
30458
|
for (const c of convs) {
|
|
29762
30459
|
const kind = c.conversation_type === "direct" ? "DM" : c.conversation_type;
|
|
29763
|
-
const unread = c.unread_count ? ` (${c.unread_count} unread)` : "";
|
|
30460
|
+
const unread = c.unread_count ? ` (${c.unread_count} unread${mentionsMe(c) ? ", @you" : ""})` : "";
|
|
29764
30461
|
console.log(label(`[${kind}] ${convLabel(c, auth.userId)}`, dim(c.id)));
|
|
29765
30462
|
if (c.last_message?.content) {
|
|
29766
30463
|
const who = c.last_message.name ? `${c.last_message.name}: ` : "";
|
|
29767
|
-
|
|
30464
|
+
const preview = mentionMarkupToPlainText(c.last_message.content);
|
|
30465
|
+
console.log(dim(` ${who}${preview.slice(0, 80)}`));
|
|
29768
30466
|
}
|
|
29769
30467
|
if (unread)
|
|
29770
30468
|
console.log(dim(` ${unread.trim()}`));
|
|
@@ -29809,15 +30507,23 @@ function registerChatCommand(chat) {
|
|
|
29809
30507
|
for (const m of msgs) {
|
|
29810
30508
|
const who = m.sender?.name ?? m.name ?? (m.sender_id ? m.sender_id.slice(0, 8) : "?");
|
|
29811
30509
|
const when = m.created_at ? m.created_at.slice(0, 19).replace("T", " ") : "";
|
|
29812
|
-
console.log(label(who, m.content));
|
|
29813
|
-
|
|
29814
|
-
|
|
30510
|
+
console.log(label(who, mentionMarkupToPlainText(m.content ?? "")));
|
|
30511
|
+
const meta3 = [when, `id ${m.id}`].filter(Boolean).join(" ");
|
|
30512
|
+
console.log(dim(` ${meta3}`));
|
|
30513
|
+
if (m.reactions?.length) {
|
|
30514
|
+
console.log(dim(` reactions: ${summarizeReactions(m.reactions)}`));
|
|
30515
|
+
}
|
|
29815
30516
|
}
|
|
29816
30517
|
});
|
|
29817
|
-
chat.command("send <target>
|
|
30518
|
+
chat.command("send <target> [message]").description("Send a message as yourself (the owner) to a conversation or person (conversation id, user id, @handle, or name — DMs are opened automatically). The message is optional only with --gif.").option("--image <pathOrUrl>", "Attach an image: local file (jpg/png/webp/gif, ≤10MB) or an Amiko URL (e.g. from `amiko create`)").option("--audio <pathOrUrl>", "Attach audio as a voice note: local file (≤25MB) or an Amiko URL").option("--gif <queryOrUrl>", "Attach a GIF: search words (sends the top Klipy result) or a Klipy URL from `amiko chat gifs`").option("--all", "Mention @all — notifies every member of the group (admins/owners always; others only when enabled via `amiko chat group mention-all`)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (target, message, opts) => {
|
|
29818
30519
|
const auth = authOrExit();
|
|
29819
|
-
|
|
29820
|
-
|
|
30520
|
+
const mediaFlagCount = [opts.image, opts.audio, opts.gif].filter(Boolean).length;
|
|
30521
|
+
if (mediaFlagCount > 1) {
|
|
30522
|
+
console.error(error("Attach only one of --image, --audio, or --gif (one media block per message)."));
|
|
30523
|
+
process.exit(1);
|
|
30524
|
+
}
|
|
30525
|
+
if (message === undefined && !opts.gif) {
|
|
30526
|
+
console.error(error("A message is required (it may only be omitted when sending a GIF with --gif)."));
|
|
29821
30527
|
process.exit(1);
|
|
29822
30528
|
}
|
|
29823
30529
|
let resolved;
|
|
@@ -29827,12 +30533,73 @@ function registerChatCommand(chat) {
|
|
|
29827
30533
|
console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
|
|
29828
30534
|
process.exit(1);
|
|
29829
30535
|
}
|
|
29830
|
-
const
|
|
30536
|
+
const wantsAll = Boolean(opts.all);
|
|
30537
|
+
const messageText = message ?? "";
|
|
30538
|
+
let outgoing = messageText;
|
|
30539
|
+
let mentionAllActive = false;
|
|
30540
|
+
if (wantsAll || hasMentionAllLiteral(messageText) || hasMentionAllToken(messageText)) {
|
|
30541
|
+
const plainTextNote = "Note: @all only notifies people in group chats — sending it as plain text.";
|
|
30542
|
+
let conv = null;
|
|
30543
|
+
if (resolved.kind === "conversation") {
|
|
30544
|
+
try {
|
|
30545
|
+
conv = resolved.conversation ?? await fetchConversationById(auth, resolved.id);
|
|
30546
|
+
} catch (e5) {
|
|
30547
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load the conversation"));
|
|
30548
|
+
process.exit(1);
|
|
30549
|
+
}
|
|
30550
|
+
}
|
|
30551
|
+
if (!conv || conv.conversation_type !== "group") {
|
|
30552
|
+
if (wantsAll) {
|
|
30553
|
+
console.error(error("@all only works in group chats — this target is a direct message."));
|
|
30554
|
+
process.exit(1);
|
|
30555
|
+
}
|
|
30556
|
+
console.log(dim(plainTextNote));
|
|
30557
|
+
} else if (!canMentionAll(conv, auth.userId)) {
|
|
30558
|
+
if (wantsAll) {
|
|
30559
|
+
console.error(error(`You can't @all in "${conv.title ?? conv.id}" — only group admins can, unless an admin allows everyone with \`amiko chat group mention-all ${JSON.stringify(target)} on\`.`));
|
|
30560
|
+
process.exit(1);
|
|
30561
|
+
}
|
|
30562
|
+
console.log(dim('Note: you don\'t have @all permission in this group — "@all" goes out as plain text. An admin can enable it with `amiko chat group mention-all <group> on`.'));
|
|
30563
|
+
} else {
|
|
30564
|
+
outgoing = rewriteMentionAllLiteral(messageText);
|
|
30565
|
+
if (wantsAll)
|
|
30566
|
+
outgoing = applyMentionAll(outgoing);
|
|
30567
|
+
mentionAllActive = true;
|
|
30568
|
+
}
|
|
30569
|
+
}
|
|
30570
|
+
let gifAttachment = null;
|
|
30571
|
+
if (opts.gif) {
|
|
30572
|
+
try {
|
|
30573
|
+
if (URL_SHAPED_RE.test(opts.gif)) {
|
|
30574
|
+
if (!HTTPS_RE.test(opts.gif) || !isKlipyMediaUrl(opts.gif)) {
|
|
30575
|
+
throw new Error("--gif accepts search words or an https Klipy URL (from `amiko chat gifs`). For other images use --image with a local file or an Amiko URL.");
|
|
30576
|
+
}
|
|
30577
|
+
gifAttachment = gifToMetadata({ url: opts.gif });
|
|
30578
|
+
} else {
|
|
30579
|
+
const results = await searchGifs(auth, opts.gif);
|
|
30580
|
+
const top = results.items?.[0];
|
|
30581
|
+
if (!top) {
|
|
30582
|
+
throw new Error(`No GIFs found for "${opts.gif}" — try different words or browse with \`amiko chat gifs\`.`);
|
|
30583
|
+
}
|
|
30584
|
+
gifAttachment = gifToMetadata({
|
|
30585
|
+
url: top.gifUrl,
|
|
30586
|
+
title: top.title,
|
|
30587
|
+
width: top.width,
|
|
30588
|
+
height: top.height
|
|
30589
|
+
});
|
|
30590
|
+
}
|
|
30591
|
+
} catch (e5) {
|
|
30592
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not resolve the GIF"));
|
|
30593
|
+
process.exit(1);
|
|
30594
|
+
}
|
|
30595
|
+
}
|
|
30596
|
+
const media = opts.image ? `image ${opts.image}` : opts.audio ? `audio ${opts.audio}` : gifAttachment ? `gif "${gifAttachment.name.slice(0, 40)}"` : "";
|
|
30597
|
+
const shown = mentionMarkupToPlainText(outgoing);
|
|
29831
30598
|
await requireApproval({
|
|
29832
30599
|
free: "sends a message as you (the owner) — the recipients see it",
|
|
29833
|
-
summary: `Send to ${resolved.label}${media ? ` [+${media}]` : ""}: ${
|
|
30600
|
+
summary: `Send to ${resolved.label}${media ? ` [+${media}]` : ""}${mentionAllActive ? " [@all — notifies every member]" : ""}: ${shown.slice(0, 80)}${shown.length > 80 ? "…" : ""}`,
|
|
29834
30601
|
yes: opts.yes,
|
|
29835
|
-
commandExample: `amiko chat send ${JSON.stringify(target)} ${JSON.stringify(message)}`
|
|
30602
|
+
commandExample: `amiko chat send ${JSON.stringify(target)}${message !== undefined ? ` ${JSON.stringify(message)}` : ""}${opts.image ? ` --image ${JSON.stringify(opts.image)}` : ""}${opts.audio ? ` --audio ${JSON.stringify(opts.audio)}` : ""}${opts.gif ? ` --gif ${JSON.stringify(opts.gif)}` : ""}${opts.all ? " --all" : ""}`
|
|
29836
30603
|
});
|
|
29837
30604
|
let convId;
|
|
29838
30605
|
try {
|
|
@@ -29849,22 +30616,35 @@ function registerChatCommand(chat) {
|
|
|
29849
30616
|
});
|
|
29850
30617
|
let data;
|
|
29851
30618
|
try {
|
|
29852
|
-
if (
|
|
30619
|
+
if (gifAttachment) {
|
|
30620
|
+
const isVideo = isKlipyVideoMediaUrl(gifAttachment.url);
|
|
30621
|
+
data = await post({
|
|
30622
|
+
content: outgoing || "GIF",
|
|
30623
|
+
message_type: isVideo ? "video" : "image",
|
|
30624
|
+
metadata: {
|
|
30625
|
+
[isVideo ? "videos" : "images"]: [gifAttachment],
|
|
30626
|
+
image_caption: outgoing || null
|
|
30627
|
+
}
|
|
30628
|
+
});
|
|
30629
|
+
} else if (opts.image) {
|
|
29853
30630
|
const img = HTTPS_RE.test(opts.image) ? { url: opts.image, name: urlName(opts.image) } : await uploadChatImage(auth, convId, opts.image);
|
|
29854
|
-
data = await post({
|
|
30631
|
+
data = await post({
|
|
30632
|
+
content: outgoing,
|
|
30633
|
+
metadata: { images: [img], image_caption: outgoing || null }
|
|
30634
|
+
});
|
|
29855
30635
|
} else if (opts.audio) {
|
|
29856
30636
|
if (HTTPS_RE.test(opts.audio)) {
|
|
29857
30637
|
data = await post({
|
|
29858
|
-
content:
|
|
30638
|
+
content: outgoing,
|
|
29859
30639
|
message_type: "voice",
|
|
29860
30640
|
metadata: { audioUrl: opts.audio }
|
|
29861
30641
|
});
|
|
29862
30642
|
} else {
|
|
29863
|
-
data = await post({ content:
|
|
30643
|
+
data = await post({ content: outgoing });
|
|
29864
30644
|
await uploadChatVoice(auth, convId, opts.audio);
|
|
29865
30645
|
}
|
|
29866
30646
|
} else {
|
|
29867
|
-
data = await post({ content:
|
|
30647
|
+
data = await post({ content: outgoing });
|
|
29868
30648
|
}
|
|
29869
30649
|
} catch (e5) {
|
|
29870
30650
|
console.error(error(e5 instanceof Error ? e5.message : "Send failed"));
|
|
@@ -29874,11 +30654,326 @@ function registerChatCommand(chat) {
|
|
|
29874
30654
|
console.log(JSON.stringify(data, null, 2));
|
|
29875
30655
|
return;
|
|
29876
30656
|
}
|
|
29877
|
-
const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : "Message sent";
|
|
30657
|
+
const what = opts.image ? "Message + image sent" : opts.audio ? "Message + voice note sent" : gifAttachment ? outgoing ? "Message + GIF sent" : "GIF sent" : "Message sent";
|
|
29878
30658
|
console.log(success(what) + dim(` (conversation ${convId.slice(0, 8)}…)`));
|
|
29879
30659
|
});
|
|
29880
|
-
|
|
30660
|
+
chat.command("react <messageId> <emoji>").description("React to a message as yourself (the owner). Get the message id from `amiko chat read`. One reaction per message — a new emoji replaces your previous one.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, emojiArg, opts) => {
|
|
30661
|
+
const auth = authOrExit();
|
|
30662
|
+
let emoji3;
|
|
30663
|
+
try {
|
|
30664
|
+
emoji3 = validateEmoji(emojiArg);
|
|
30665
|
+
} catch (e5) {
|
|
30666
|
+
console.error(error(e5 instanceof Error ? e5.message : "Invalid emoji"));
|
|
30667
|
+
process.exit(1);
|
|
30668
|
+
}
|
|
30669
|
+
let msg;
|
|
30670
|
+
try {
|
|
30671
|
+
msg = await fetchMessage(auth, messageId);
|
|
30672
|
+
} catch (e5) {
|
|
30673
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
|
|
30674
|
+
process.exit(1);
|
|
30675
|
+
}
|
|
30676
|
+
if (ownReaction(msg.reactions, auth.userId) === emoji3) {
|
|
30677
|
+
if (opts.raw) {
|
|
30678
|
+
console.log(JSON.stringify({ success: true, action: "unchanged", reactions: msg.reactions }, null, 2));
|
|
30679
|
+
return;
|
|
30680
|
+
}
|
|
30681
|
+
console.log(success(`Already reacted with ${emoji3}`));
|
|
30682
|
+
return;
|
|
30683
|
+
}
|
|
30684
|
+
await requireApproval({
|
|
30685
|
+
free: "adds your reaction — people in the chat see it",
|
|
30686
|
+
summary: `React ${emoji3} to "${previewText(msg)}"`,
|
|
30687
|
+
yes: opts.yes,
|
|
30688
|
+
commandExample: `amiko chat react ${JSON.stringify(messageId)} ${JSON.stringify(emoji3)}`
|
|
30689
|
+
});
|
|
30690
|
+
let data;
|
|
30691
|
+
try {
|
|
30692
|
+
data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/reactions`, { method: "POST", body: { emoji: emoji3 }, timeoutMs: 20000 });
|
|
30693
|
+
} catch (e5) {
|
|
30694
|
+
console.error(error(e5 instanceof Error ? e5.message : "Failed to react"));
|
|
30695
|
+
process.exit(1);
|
|
30696
|
+
}
|
|
30697
|
+
if (opts.raw) {
|
|
30698
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30699
|
+
return;
|
|
30700
|
+
}
|
|
30701
|
+
const what = data.action === "removed" ? `Removed your ${emoji3} reaction` : data.action === "updated" ? `Reaction changed to ${emoji3}` : `Reacted ${emoji3}`;
|
|
30702
|
+
console.log(success(what));
|
|
30703
|
+
});
|
|
30704
|
+
chat.command("unreact <messageId>").description("Remove your (the owner's) reaction from a message. Get the message id from `amiko chat read`.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
|
|
30705
|
+
const auth = authOrExit();
|
|
30706
|
+
let msg;
|
|
30707
|
+
try {
|
|
30708
|
+
msg = await fetchMessage(auth, messageId);
|
|
30709
|
+
} catch (e5) {
|
|
30710
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
|
|
30711
|
+
process.exit(1);
|
|
30712
|
+
}
|
|
30713
|
+
const mine = ownReaction(msg.reactions, auth.userId);
|
|
30714
|
+
if (!mine) {
|
|
30715
|
+
if (opts.raw) {
|
|
30716
|
+
console.log(JSON.stringify({ success: true, action: "none", reactions: msg.reactions }, null, 2));
|
|
30717
|
+
return;
|
|
30718
|
+
}
|
|
30719
|
+
console.log(dim("You have no reaction on that message."));
|
|
30720
|
+
return;
|
|
30721
|
+
}
|
|
30722
|
+
await requireApproval({
|
|
30723
|
+
free: "removes your reaction from the chat",
|
|
30724
|
+
summary: `Remove your ${mine} reaction from "${previewText(msg)}"`,
|
|
30725
|
+
yes: opts.yes,
|
|
30726
|
+
commandExample: `amiko chat unreact ${JSON.stringify(messageId)}`
|
|
30727
|
+
});
|
|
30728
|
+
let data;
|
|
30729
|
+
try {
|
|
30730
|
+
data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/reactions`, { method: "POST", body: { emoji: mine }, timeoutMs: 20000 });
|
|
30731
|
+
} catch (e5) {
|
|
30732
|
+
console.error(error(e5 instanceof Error ? e5.message : "Failed to remove reaction"));
|
|
30733
|
+
process.exit(1);
|
|
30734
|
+
}
|
|
30735
|
+
if (opts.raw) {
|
|
30736
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30737
|
+
return;
|
|
30738
|
+
}
|
|
30739
|
+
const what = data.action === "removed" ? `Removed your ${mine} reaction` : data.action === "updated" ? `Reaction changed to ${mine}` : `Reacted ${mine}`;
|
|
30740
|
+
console.log(success(what));
|
|
30741
|
+
});
|
|
30742
|
+
chat.command("reactions <messageId>").description("Show the reactions on a message. Get the message id from `amiko chat read`.").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
|
|
30743
|
+
const auth = authOrExit();
|
|
30744
|
+
let msg;
|
|
30745
|
+
try {
|
|
30746
|
+
msg = await fetchMessage(auth, messageId);
|
|
30747
|
+
} catch (e5) {
|
|
30748
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
|
|
30749
|
+
process.exit(1);
|
|
30750
|
+
}
|
|
30751
|
+
if (opts.raw) {
|
|
30752
|
+
console.log(JSON.stringify({ reactions: msg.reactions }, null, 2));
|
|
30753
|
+
return;
|
|
30754
|
+
}
|
|
30755
|
+
if (msg.reactions.length === 0) {
|
|
30756
|
+
console.log(dim("No reactions yet."));
|
|
30757
|
+
return;
|
|
30758
|
+
}
|
|
30759
|
+
const mine = ownReaction(msg.reactions, auth.userId);
|
|
30760
|
+
console.log(heading(`Reactions (${msg.reactions.length})
|
|
30761
|
+
`));
|
|
30762
|
+
for (const [emoji3, count] of groupReactions(msg.reactions)) {
|
|
30763
|
+
const you = emoji3 === mine ? dim(" (includes you)") : "";
|
|
30764
|
+
console.log(label(emoji3, `×${count}`) + you);
|
|
30765
|
+
}
|
|
30766
|
+
});
|
|
30767
|
+
chat.command("receipts <messageId>").description("Show who read your message and when (sender-only). Get the message id from `amiko chat read`.").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
|
|
30768
|
+
const auth = authOrExit();
|
|
30769
|
+
let msg;
|
|
30770
|
+
try {
|
|
30771
|
+
msg = await fetchMessage(auth, messageId);
|
|
30772
|
+
} catch (e5) {
|
|
30773
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
|
|
30774
|
+
process.exit(1);
|
|
30775
|
+
}
|
|
30776
|
+
let data;
|
|
30777
|
+
try {
|
|
30778
|
+
data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/receipts`, { timeoutMs: 20000 });
|
|
30779
|
+
} catch (e5) {
|
|
30780
|
+
const status = e5.status;
|
|
30781
|
+
console.error(error(status === 403 ? "Read receipts are only available for messages you sent (as the owner)." : e5 instanceof Error ? e5.message : "Failed to load receipts"));
|
|
30782
|
+
process.exit(1);
|
|
30783
|
+
}
|
|
30784
|
+
const reads = data.reads ?? [];
|
|
30785
|
+
const delivered = data.delivered_to ?? [];
|
|
30786
|
+
if (opts.raw) {
|
|
30787
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30788
|
+
return;
|
|
30789
|
+
}
|
|
30790
|
+
const names = new Map;
|
|
30791
|
+
if (msg.conversation_id) {
|
|
30792
|
+
try {
|
|
30793
|
+
const conv = await fetchConversationById(auth, msg.conversation_id);
|
|
30794
|
+
for (const p of conv.participants ?? []) {
|
|
30795
|
+
if (p.participant_type !== "user")
|
|
30796
|
+
continue;
|
|
30797
|
+
const id = normalizeUserId(p.participant_id);
|
|
30798
|
+
names.set(id, p.user?.name ?? p.user?.handle ?? id.slice(0, 8));
|
|
30799
|
+
}
|
|
30800
|
+
} catch {}
|
|
30801
|
+
}
|
|
30802
|
+
const nameFor = (userId) => {
|
|
30803
|
+
const id = normalizeUserId(userId);
|
|
30804
|
+
return names.get(id) ?? id.slice(0, 8);
|
|
30805
|
+
};
|
|
30806
|
+
const fmt = (iso) => iso.slice(0, 19).replace("T", " ");
|
|
30807
|
+
console.log(label("Message", previewText(msg)));
|
|
30808
|
+
console.log(label("Sent", fmt(data.sent_at)));
|
|
30809
|
+
console.log("");
|
|
30810
|
+
console.log(heading(`Read by (${reads.length})`));
|
|
30811
|
+
if (reads.length === 0) {
|
|
30812
|
+
console.log(dim(" No one has read this yet."));
|
|
30813
|
+
} else {
|
|
30814
|
+
for (const r of reads) {
|
|
30815
|
+
const when = r.approximate ? dim("read (exact time unknown)") : fmt(r.read_at);
|
|
30816
|
+
console.log(` ${label(nameFor(r.user_id), when)}`);
|
|
30817
|
+
}
|
|
30818
|
+
}
|
|
30819
|
+
if (delivered.length > 0) {
|
|
30820
|
+
console.log("");
|
|
30821
|
+
console.log(heading(`Delivered to (${delivered.length})`));
|
|
30822
|
+
for (const userId of delivered) {
|
|
30823
|
+
console.log(` ${nameFor(userId)}`);
|
|
30824
|
+
}
|
|
30825
|
+
}
|
|
30826
|
+
});
|
|
30827
|
+
chat.command("pin <messageId>").description("Pin a message for everyone in the conversation. Get the message id from `amiko chat read`. Groups require the admin role; the chat sees a pinned-message announcement.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
|
|
30828
|
+
const auth = authOrExit();
|
|
30829
|
+
let msg;
|
|
30830
|
+
try {
|
|
30831
|
+
msg = await fetchMessage(auth, messageId);
|
|
30832
|
+
} catch (e5) {
|
|
30833
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
|
|
30834
|
+
process.exit(1);
|
|
30835
|
+
}
|
|
30836
|
+
await requireApproval({
|
|
30837
|
+
free: "pins the message for everyone — the chat sees a pinned-message announcement",
|
|
30838
|
+
summary: `Pin "${previewText(msg)}"`,
|
|
30839
|
+
yes: opts.yes,
|
|
30840
|
+
commandExample: `amiko chat pin ${JSON.stringify(messageId)}`
|
|
30841
|
+
});
|
|
30842
|
+
let data;
|
|
30843
|
+
try {
|
|
30844
|
+
data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/pin`, { method: "POST", timeoutMs: 20000 });
|
|
30845
|
+
} catch (e5) {
|
|
30846
|
+
console.error(error(pinErrorMessage(e5, "Failed to pin the message")));
|
|
30847
|
+
process.exit(1);
|
|
30848
|
+
}
|
|
30849
|
+
if (opts.raw) {
|
|
30850
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30851
|
+
return;
|
|
30852
|
+
}
|
|
30853
|
+
console.log(success(`Pinned "${previewText(msg)}"`));
|
|
30854
|
+
const total = data.pins?.length;
|
|
30855
|
+
if (typeof total === "number") {
|
|
30856
|
+
console.log(dim(` ${total}/${PIN_LIMIT} pinned in this conversation — see them with \`amiko chat pinned\`.`));
|
|
30857
|
+
}
|
|
30858
|
+
});
|
|
30859
|
+
chat.command("unpin <messageId>").description("Unpin a message — removes it from the conversation's pinned list for everyone. Get the message id from `amiko chat read` or `amiko chat pinned`.").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (messageId, opts) => {
|
|
30860
|
+
const auth = authOrExit();
|
|
30861
|
+
let msg;
|
|
30862
|
+
try {
|
|
30863
|
+
msg = await fetchMessage(auth, messageId);
|
|
30864
|
+
} catch (e5) {
|
|
30865
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not load that message"));
|
|
30866
|
+
process.exit(1);
|
|
30867
|
+
}
|
|
30868
|
+
await confirmDestructive({
|
|
30869
|
+
action: `Unpin "${previewText(msg)}" for everyone in the conversation`,
|
|
30870
|
+
yes: opts.yes,
|
|
30871
|
+
commandExample: `amiko chat unpin ${JSON.stringify(messageId)}`
|
|
30872
|
+
});
|
|
30873
|
+
let data;
|
|
30874
|
+
try {
|
|
30875
|
+
data = await amikoWebFetch(auth, `/api/messages/${encodeURIComponent(messageId)}/pin`, { method: "DELETE", timeoutMs: 20000 });
|
|
30876
|
+
} catch (e5) {
|
|
30877
|
+
console.error(error(pinErrorMessage(e5, "Failed to unpin the message")));
|
|
30878
|
+
process.exit(1);
|
|
30879
|
+
}
|
|
30880
|
+
if (opts.raw) {
|
|
30881
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30882
|
+
return;
|
|
30883
|
+
}
|
|
30884
|
+
console.log(success(`Unpinned "${previewText(msg)}"`));
|
|
30885
|
+
const remaining = data.pins?.length;
|
|
30886
|
+
if (typeof remaining === "number" && remaining > 0) {
|
|
30887
|
+
console.log(dim(` ${remaining} still pinned in this conversation.`));
|
|
30888
|
+
}
|
|
30889
|
+
});
|
|
30890
|
+
chat.command("pinned <target>").description("List a conversation's pinned messages, oldest first (conversation id, user id, @handle, or name)").option("--raw", "Output raw JSON").action(async (target, opts) => {
|
|
30891
|
+
const auth = authOrExit();
|
|
30892
|
+
let convId;
|
|
30893
|
+
try {
|
|
30894
|
+
const resolved = await resolveConversation(auth, target);
|
|
30895
|
+
if (resolved.kind === "user") {
|
|
30896
|
+
const existing = await findDmByUserId(auth, resolved.userId);
|
|
30897
|
+
if (!existing) {
|
|
30898
|
+
throw new Error(`No ${resolved.label} yet — start one with \`amiko chat send\`.`);
|
|
30899
|
+
}
|
|
30900
|
+
convId = existing;
|
|
30901
|
+
} else {
|
|
30902
|
+
convId = resolved.id;
|
|
30903
|
+
}
|
|
30904
|
+
} catch (e5) {
|
|
30905
|
+
console.error(error(e5 instanceof Error ? e5.message : "Could not resolve target"));
|
|
30906
|
+
process.exit(1);
|
|
30907
|
+
}
|
|
30908
|
+
let data;
|
|
30909
|
+
try {
|
|
30910
|
+
data = await amikoWebFetch(auth, `/api/conversations/${encodeURIComponent(convId)}/pinned-messages`, { timeoutMs: 20000 });
|
|
30911
|
+
} catch (e5) {
|
|
30912
|
+
console.error(error(e5 instanceof Error ? e5.message : "Failed to load pinned messages"));
|
|
30913
|
+
process.exit(1);
|
|
30914
|
+
}
|
|
30915
|
+
if (opts.raw) {
|
|
30916
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30917
|
+
return;
|
|
30918
|
+
}
|
|
30919
|
+
const pins = data.pins ?? [];
|
|
30920
|
+
if (pins.length === 0) {
|
|
30921
|
+
console.log(dim("No pinned messages."));
|
|
30922
|
+
return;
|
|
30923
|
+
}
|
|
30924
|
+
console.log(heading(`Pinned messages (${pins.length})
|
|
30925
|
+
`));
|
|
30926
|
+
for (const pin of pins) {
|
|
30927
|
+
const m = pin.message;
|
|
30928
|
+
const who = m.name ?? (m.sender_id ? m.sender_id.slice(0, 8) : "?");
|
|
30929
|
+
const when = m.created_at ? m.created_at.slice(0, 19).replace("T", " ") : "";
|
|
30930
|
+
console.log(label(who, mentionMarkupToPlainText(m.content ?? "")));
|
|
30931
|
+
const meta3 = [when, `id ${m.id}`].filter(Boolean).join(" ");
|
|
30932
|
+
console.log(dim(` ${meta3}`));
|
|
30933
|
+
if (pin.pinned_by_name) {
|
|
30934
|
+
console.log(dim(` pinned by ${pin.pinned_by_name}`));
|
|
30935
|
+
}
|
|
30936
|
+
}
|
|
30937
|
+
});
|
|
30938
|
+
chat.command("mark-read").description("Mark ALL conversations as read (requires --all). Clears every unread badge on the owner's devices; senders see read ticks.").option("--all", "Mark every conversation as read").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--raw", "Output raw JSON").action(async (opts) => {
|
|
30939
|
+
if (!opts.all) {
|
|
30940
|
+
console.error(error("Pass --all to mark every conversation as read"));
|
|
30941
|
+
process.exit(1);
|
|
30942
|
+
}
|
|
30943
|
+
const auth = authOrExit();
|
|
30944
|
+
await confirmDestructive({
|
|
30945
|
+
action: "Mark ALL of the owner's conversations as read",
|
|
30946
|
+
detail: "Clears every unread badge on web and mobile; senders see read ticks on their messages. This cannot be undone.",
|
|
30947
|
+
yes: opts.yes,
|
|
30948
|
+
commandExample: "amiko chat mark-read --all"
|
|
30949
|
+
});
|
|
30950
|
+
let data;
|
|
30951
|
+
try {
|
|
30952
|
+
data = await amikoWebFetch(auth, "/api/conversations/read-all", {
|
|
30953
|
+
method: "POST",
|
|
30954
|
+
timeoutMs: 20000
|
|
30955
|
+
});
|
|
30956
|
+
} catch (e5) {
|
|
30957
|
+
console.error(error(e5 instanceof Error ? e5.message : "Failed to mark all as read"));
|
|
30958
|
+
process.exit(1);
|
|
30959
|
+
}
|
|
30960
|
+
if (opts.raw) {
|
|
30961
|
+
console.log(JSON.stringify(data, null, 2));
|
|
30962
|
+
return;
|
|
30963
|
+
}
|
|
30964
|
+
console.log(success("All conversations marked as read."));
|
|
30965
|
+
const cleared = data.cleared_notifications;
|
|
30966
|
+
if (typeof cleared === "number" && cleared > 0) {
|
|
30967
|
+
console.log(dim(` ${cleared} chat notification${cleared === 1 ? "" : "s"} cleared`));
|
|
30968
|
+
} else if ((data.unread_conversation_ids ?? []).length === 0) {
|
|
30969
|
+
console.log(dim(" Nothing was unread."));
|
|
30970
|
+
}
|
|
30971
|
+
});
|
|
30972
|
+
registerChatGifsCommand(chat);
|
|
30973
|
+
const group = chat.command("group").description("Group chats — create, list, info, rename, add/remove members, @all permission, leave");
|
|
29881
30974
|
registerChatGroupCommand(group);
|
|
30975
|
+
const lists = chat.command("lists").description("Your chat lists (private folders of conversations) — run bare to see them; create, rename, add/remove conversations, delete");
|
|
30976
|
+
registerChatListsCommand(lists);
|
|
29882
30977
|
}
|
|
29883
30978
|
|
|
29884
30979
|
// src/commands/card.ts
|
|
@@ -30314,18 +31409,32 @@ async function fetchWithTimeout2(url2, init = {}, timeoutMs) {
|
|
|
30314
31409
|
}
|
|
30315
31410
|
function registerServicesCommand(program2) {
|
|
30316
31411
|
const svc = program2.command("service").alias("svc").description("Call any MPP service — pay in AMIKO, USDC, or USDT");
|
|
30317
|
-
svc.command("tts <voiceId> <text>").description("Text-to-speech
|
|
31412
|
+
svc.command("tts <voiceId> <text>").description("Text-to-speech via mpp /v1/tts (pay-per-call)").option("--provider <name>", "elevenlabs (default) | minimax").option("--model <model>", "MiniMax speech-* model when provider=minimax").option("--raw", "Output raw response").option("--wallet <address>", "Wallet to pay from (defaults to your Solana wallet — run `amiko wallets create --chain solana` if none)").option("--yes", "Skip the pre-spend confirmation (required in non-interactive shells)").action(async (voiceId, text, opts) => {
|
|
31413
|
+
const provider = typeof opts.provider === "string" ? opts.provider.trim().toLowerCase() : "";
|
|
31414
|
+
const body = { text };
|
|
31415
|
+
if (provider === "minimax") {
|
|
31416
|
+
body.provider = "minimax";
|
|
31417
|
+
if (opts.model)
|
|
31418
|
+
body.model_id = opts.model;
|
|
31419
|
+
else
|
|
31420
|
+
body.model_id = "speech-2.8-turbo";
|
|
31421
|
+
} else if (provider && provider !== "elevenlabs") {
|
|
31422
|
+
console.error(error("--provider must be minimax or elevenlabs"));
|
|
31423
|
+
process.exit(1);
|
|
31424
|
+
}
|
|
31425
|
+
const providerArgs = provider ? ` --provider ${provider}` : "";
|
|
31426
|
+
const modelArgs = provider === "minimax" ? ` --model ${JSON.stringify(typeof opts.model === "string" && opts.model ? opts.model : "speech-2.8-turbo")}` : "";
|
|
30318
31427
|
await requireApproval({
|
|
30319
|
-
cost: "
|
|
30320
|
-
summary: `Text-to-speech (voice ${voiceId}): "${text.slice(0, 60)}${text.length > 60 ? "…" : ""}"`,
|
|
31428
|
+
cost: "variable (see mpp pricing)",
|
|
31429
|
+
summary: `Text-to-speech (voice ${voiceId}${provider ? `, ${provider}` : ""}): "${text.slice(0, 60)}${text.length > 60 ? "…" : ""}"`,
|
|
30321
31430
|
yes: opts.yes,
|
|
30322
|
-
commandExample: `amiko markets service tts ${voiceId} ${JSON.stringify(text)}`
|
|
31431
|
+
commandExample: `amiko markets service tts ${voiceId} ${JSON.stringify(text)}${providerArgs}${modelArgs}`
|
|
30323
31432
|
});
|
|
30324
31433
|
await callService({
|
|
30325
31434
|
name: "Text-to-Speech",
|
|
30326
31435
|
method: "POST",
|
|
30327
31436
|
path: `/v1/tts/${encodeURIComponent(voiceId)}`,
|
|
30328
|
-
body
|
|
31437
|
+
body,
|
|
30329
31438
|
amiko: "1",
|
|
30330
31439
|
opts
|
|
30331
31440
|
});
|
|
@@ -30537,7 +31646,7 @@ function registerAccountsCommand(program2) {
|
|
|
30537
31646
|
}
|
|
30538
31647
|
}, { json: opts.json });
|
|
30539
31648
|
});
|
|
30540
|
-
program2.command("info").description("Get twin info (name, description, voice, avatar)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
|
|
31649
|
+
program2.command("info").description("Get twin info (name, description, voice ids, avatar)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
|
|
30541
31650
|
const config2 = loadConfig();
|
|
30542
31651
|
const auth = resolveAuth();
|
|
30543
31652
|
let twinId;
|
|
@@ -30561,13 +31670,24 @@ function registerAccountsCommand(program2) {
|
|
|
30561
31670
|
console.log(label("Description", t.description));
|
|
30562
31671
|
console.log(label("Public", t.is_public ? "yes" : "no"));
|
|
30563
31672
|
if (t.voice_status)
|
|
30564
|
-
console.log(label("Voice", t.voice_status));
|
|
31673
|
+
console.log(label("Voice status", t.voice_status));
|
|
31674
|
+
if (t.voice_id)
|
|
31675
|
+
console.log(label("ElevenLabs voice_id", t.voice_id));
|
|
31676
|
+
else
|
|
31677
|
+
console.log(label("ElevenLabs voice_id", dim("(none)")));
|
|
31678
|
+
if (t.minimax_voice_id)
|
|
31679
|
+
console.log(label("MiniMax voice_id", t.minimax_voice_id));
|
|
31680
|
+
else
|
|
31681
|
+
console.log(label("MiniMax voice_id", dim("(none — clone with amiko voice clone)")));
|
|
30565
31682
|
if (t.avatar_url)
|
|
30566
31683
|
console.log(label("Avatar", t.avatar_url));
|
|
30567
31684
|
if (t.shipped_at)
|
|
30568
31685
|
console.log(label("Shipped", t.shipped_at));
|
|
30569
31686
|
if (t.updated_at)
|
|
30570
31687
|
console.log(dim(`Updated ${t.updated_at}`));
|
|
31688
|
+
console.log("");
|
|
31689
|
+
console.log(dim('MiniMax TTS: amiko create tts "…" --provider minimax --voice <MiniMax voice_id> --yes'));
|
|
31690
|
+
console.log(dim('ElevenLabs TTS: amiko create tts "…" --provider elevenlabs --voice <ElevenLabs voice_id> --yes'));
|
|
30571
31691
|
}, { json: opts.json });
|
|
30572
31692
|
} catch (e5) {
|
|
30573
31693
|
spinner?.stop();
|
|
@@ -30649,6 +31769,9 @@ function registerTwinCommand(program2) {
|
|
|
30649
31769
|
// src/commands/drive.ts
|
|
30650
31770
|
import { writeFile as writeFile2 } from "node:fs/promises";
|
|
30651
31771
|
import { basename as basename2, resolve as resolvePath } from "node:path";
|
|
31772
|
+
var DIRECT_UPLOAD_MAX_BYTES = 4 * 1024 * 1024;
|
|
31773
|
+
var MAX_DRIVE_UPLOAD_BYTES = 200 * 1024 * 1024;
|
|
31774
|
+
var PRESIGNED_PUT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
30652
31775
|
function shareBase(auth) {
|
|
30653
31776
|
return (auth.platformUrl ?? "https://platform.heyamiko.com").replace(/\/$/, "");
|
|
30654
31777
|
}
|
|
@@ -30783,26 +31906,56 @@ Search hits filename, title, description, and parsed RAG content (once processed
|
|
|
30783
31906
|
try {
|
|
30784
31907
|
const file2 = await resolveFileInput(source, {
|
|
30785
31908
|
stdin: opts.stdin,
|
|
30786
|
-
hintName: opts.name
|
|
31909
|
+
hintName: opts.name,
|
|
31910
|
+
maxBytes: MAX_DRIVE_UPLOAD_BYTES
|
|
30787
31911
|
});
|
|
30788
31912
|
if (spinner)
|
|
30789
|
-
spinner.text = `Uploading ${file2.name}...`;
|
|
30790
|
-
|
|
30791
|
-
|
|
30792
|
-
|
|
30793
|
-
|
|
30794
|
-
|
|
30795
|
-
|
|
30796
|
-
|
|
30797
|
-
|
|
31913
|
+
spinner.text = `Uploading ${file2.name} (${fmtBytes(file2.size)})...`;
|
|
31914
|
+
let uploadedUrl;
|
|
31915
|
+
let uploadedName;
|
|
31916
|
+
let uploadedType;
|
|
31917
|
+
if (file2.size > DIRECT_UPLOAD_MAX_BYTES) {
|
|
31918
|
+
const pres = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/presigned-url`, {
|
|
31919
|
+
method: "POST",
|
|
31920
|
+
body: { filename: file2.name }
|
|
31921
|
+
});
|
|
31922
|
+
if (!pres.uploadUrl || !pres.publicUrl) {
|
|
31923
|
+
throw new Error(`Presigned-URL response missing fields: ${JSON.stringify(pres)}`);
|
|
31924
|
+
}
|
|
31925
|
+
const putRes = await fetch(pres.uploadUrl, {
|
|
31926
|
+
method: "PUT",
|
|
31927
|
+
headers: { "Content-Type": file2.mime },
|
|
31928
|
+
body: new Uint8Array(file2.buffer),
|
|
31929
|
+
signal: AbortSignal.timeout(PRESIGNED_PUT_TIMEOUT_MS)
|
|
31930
|
+
});
|
|
31931
|
+
if (!putRes.ok) {
|
|
31932
|
+
const detail = await putRes.text().catch(() => "");
|
|
31933
|
+
throw new Error(`Direct storage upload failed: ${putRes.status} ${putRes.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
|
|
31934
|
+
}
|
|
31935
|
+
uploadedUrl = pres.publicUrl;
|
|
31936
|
+
uploadedName = file2.name;
|
|
31937
|
+
uploadedType = file2.mime;
|
|
31938
|
+
} else {
|
|
31939
|
+
const form = new FormData;
|
|
31940
|
+
form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
|
|
31941
|
+
const upload = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/upload`, {
|
|
31942
|
+
method: "POST",
|
|
31943
|
+
multipart: form
|
|
31944
|
+
});
|
|
31945
|
+
if (!upload.url || !upload.filename || !upload.fileType) {
|
|
31946
|
+
throw new Error(`Upload response missing required fields: ${JSON.stringify(upload)}`);
|
|
31947
|
+
}
|
|
31948
|
+
uploadedUrl = upload.url;
|
|
31949
|
+
uploadedName = upload.filename;
|
|
31950
|
+
uploadedType = upload.fileType;
|
|
30798
31951
|
}
|
|
30799
31952
|
if (spinner)
|
|
30800
|
-
spinner.text = `Registering ${
|
|
30801
|
-
const title = opts.title ||
|
|
31953
|
+
spinner.text = `Registering ${uploadedName}...`;
|
|
31954
|
+
const title = opts.title || uploadedName.replace(/\.[^.]+$/, "");
|
|
30802
31955
|
const body = {
|
|
30803
|
-
filename:
|
|
30804
|
-
fileUrl:
|
|
30805
|
-
fileType:
|
|
31956
|
+
filename: uploadedName,
|
|
31957
|
+
fileUrl: uploadedUrl,
|
|
31958
|
+
fileType: uploadedType,
|
|
30806
31959
|
title,
|
|
30807
31960
|
doc_type: opts.docType ?? "other"
|
|
30808
31961
|
};
|
|
@@ -30818,10 +31971,8 @@ Search hits filename, title, description, and parsed RAG content (once processed
|
|
|
30818
31971
|
console.log(label("Title", d.title || "-"));
|
|
30819
31972
|
if (d.folder_id)
|
|
30820
31973
|
console.log(label("Folder", d.folder_id));
|
|
30821
|
-
|
|
30822
|
-
|
|
30823
|
-
if (upload.fileType)
|
|
30824
|
-
console.log(label("Type", upload.fileType));
|
|
31974
|
+
console.log(label("Size", fmtBytes(file2.size)));
|
|
31975
|
+
console.log(label("Type", uploadedType));
|
|
30825
31976
|
if (d.job_id) {
|
|
30826
31977
|
console.log(label("RAG job", d.job_id));
|
|
30827
31978
|
console.log(dim("Processing runs in the background. Use `amiko drive status <id>` to check."));
|
|
@@ -31173,8 +32324,18 @@ function twinFromFlag2(opts) {
|
|
|
31173
32324
|
throw e5;
|
|
31174
32325
|
}
|
|
31175
32326
|
}
|
|
32327
|
+
function parseProvider(raw, fallback2) {
|
|
32328
|
+
if (raw == null || raw.trim() === "")
|
|
32329
|
+
return fallback2;
|
|
32330
|
+
const p = raw.trim().toLowerCase();
|
|
32331
|
+
if (p !== "minimax" && p !== "elevenlabs") {
|
|
32332
|
+
console.error(error("--provider must be minimax or elevenlabs"));
|
|
32333
|
+
process.exit(1);
|
|
32334
|
+
}
|
|
32335
|
+
return p;
|
|
32336
|
+
}
|
|
31176
32337
|
function registerVoiceCommand(program2) {
|
|
31177
|
-
program2.command("design").description("Design a voice from a text description").requiredOption("--description <text>", "Voice description (min 20 chars)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
|
|
32338
|
+
program2.command("design").description("Design a voice from a text description (ElevenLabs)").requiredOption("--description <text>", "Voice description (min 20 chars)").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
|
|
31178
32339
|
if (opts.description.trim().length < 20) {
|
|
31179
32340
|
console.error(error("--description must be at least 20 characters"));
|
|
31180
32341
|
process.exit(1);
|
|
@@ -31202,7 +32363,7 @@ function registerVoiceCommand(program2) {
|
|
|
31202
32363
|
process.exit(1);
|
|
31203
32364
|
}
|
|
31204
32365
|
});
|
|
31205
|
-
program2.command("create").description("Create a voice from a previously designed preview").requiredOption("--sample <id>", "generated_voice_id from voice design").option("--description <text>", "Optional voice description").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
|
|
32366
|
+
program2.command("create").description("Create a voice from a previously designed preview (ElevenLabs)").requiredOption("--sample <id>", "generated_voice_id from voice design").option("--description <text>", "Optional voice description").option("--twin <idOrName>", "Target twin id or name").option("--json", "Output as JSON").action(async (opts) => {
|
|
31206
32367
|
const { twinId, auth } = twinFromFlag2(opts);
|
|
31207
32368
|
const spinner = opts.json ? null : ora("Creating voice...").start();
|
|
31208
32369
|
try {
|
|
@@ -31228,7 +32389,8 @@ function registerVoiceCommand(program2) {
|
|
|
31228
32389
|
process.exit(1);
|
|
31229
32390
|
}
|
|
31230
32391
|
});
|
|
31231
|
-
program2.command("clone <source>").description("Clone a voice from an audio file (path | https URL | -)").option("--twin <idOrName>", "Target twin id or name").option("--name <filename>", "Override filename").option("--stdin", "Read from stdin (source must be -)").option("--json", "Output as JSON").action(async (source, opts) => {
|
|
32392
|
+
program2.command("clone <source>").description("Clone a voice from an audio file (path | https URL | -). Default provider: minimax").option("--provider <name>", "minimax (default, mpp rapid clone) | elevenlabs (platform IVC)", "minimax").option("--voice-id <id>", "MiniMax: optional custom voice_id (8–256 chars)").option("--model <model>", "MiniMax preview TTS model", "speech-2.8-turbo").option("--preview-text <text>", "MiniMax: preview line (empty string skips preview)").option("--twin <idOrName>", "Target twin id or name").option("--name <filename>", "Override filename").option("--stdin", "Read from stdin (source must be -)").option("--json", "Output as JSON").action(async (source, opts) => {
|
|
32393
|
+
const provider = parseProvider(opts.provider, "minimax");
|
|
31232
32394
|
const { twinId, auth } = twinFromFlag2(opts);
|
|
31233
32395
|
const spinner = opts.json ? null : ora("Reading audio...").start();
|
|
31234
32396
|
try {
|
|
@@ -31238,17 +32400,30 @@ function registerVoiceCommand(program2) {
|
|
|
31238
32400
|
hintMime: "audio/mpeg"
|
|
31239
32401
|
});
|
|
31240
32402
|
if (spinner)
|
|
31241
|
-
spinner.text = `Cloning voice from ${file2.name}...`;
|
|
32403
|
+
spinner.text = `Cloning voice (${provider}) from ${file2.name}...`;
|
|
31242
32404
|
const form = new FormData;
|
|
31243
32405
|
form.set("audio", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
|
|
32406
|
+
form.set("provider", provider);
|
|
32407
|
+
if (provider === "minimax") {
|
|
32408
|
+
if (opts.voiceId)
|
|
32409
|
+
form.set("voice_id", opts.voiceId);
|
|
32410
|
+
if (opts.model)
|
|
32411
|
+
form.set("model_id", opts.model);
|
|
32412
|
+
if (opts.previewText !== undefined)
|
|
32413
|
+
form.set("text", opts.previewText);
|
|
32414
|
+
}
|
|
31244
32415
|
const result = await amikoWebFetch(auth, `/api/agents/${twinId}/voice/clone`, { method: "POST", multipart: form, timeoutMs: 180000 });
|
|
31245
32416
|
spinner?.stop();
|
|
31246
32417
|
renderOutput(result, (r) => {
|
|
31247
|
-
console.log(success(
|
|
32418
|
+
console.log(success(`Voice cloned (${String(r.provider ?? provider)})`));
|
|
31248
32419
|
if (r.voice_id)
|
|
31249
32420
|
console.log(label("Voice ID", String(r.voice_id)));
|
|
31250
32421
|
if (r.voice_status)
|
|
31251
32422
|
console.log(label("Status", String(r.voice_status)));
|
|
32423
|
+
if (r.demo_audio)
|
|
32424
|
+
console.log(label("Demo", String(r.demo_audio)));
|
|
32425
|
+
console.log("");
|
|
32426
|
+
console.log(dim(`Synthesize: amiko create tts "Hello" --provider ${provider} --voice ${String(r.voice_id ?? "<id>")} --yes`));
|
|
31252
32427
|
}, { json: opts.json });
|
|
31253
32428
|
} catch (e5) {
|
|
31254
32429
|
spinner?.stop();
|
|
@@ -31469,16 +32644,206 @@ async function mppFriendReportGenerate(auth, body, opts = {}) {
|
|
|
31469
32644
|
body: data
|
|
31470
32645
|
};
|
|
31471
32646
|
}
|
|
31472
|
-
return { ok: true, status: res.status, body: data, txHash, amikoAmount };
|
|
31473
|
-
} catch (err) {
|
|
31474
|
-
return {
|
|
31475
|
-
ok: false,
|
|
31476
|
-
status: 0,
|
|
31477
|
-
error: err instanceof Error ? err.message : "mpp-service fetch failed",
|
|
31478
|
-
stage: "generate"
|
|
31479
|
-
};
|
|
32647
|
+
return { ok: true, status: res.status, body: data, txHash, amikoAmount };
|
|
32648
|
+
} catch (err) {
|
|
32649
|
+
return {
|
|
32650
|
+
ok: false,
|
|
32651
|
+
status: 0,
|
|
32652
|
+
error: err instanceof Error ? err.message : "mpp-service fetch failed",
|
|
32653
|
+
stage: "generate"
|
|
32654
|
+
};
|
|
32655
|
+
}
|
|
32656
|
+
}
|
|
32657
|
+
|
|
32658
|
+
// src/lib/nickname.ts
|
|
32659
|
+
var NICKNAME_MAX_CODE_POINTS = 50;
|
|
32660
|
+
var CONTROL_CHARS = /\p{Cc}/u;
|
|
32661
|
+
function validateNickname(input) {
|
|
32662
|
+
if (CONTROL_CHARS.test(input)) {
|
|
32663
|
+
return {
|
|
32664
|
+
ok: false,
|
|
32665
|
+
error: "invalid_characters",
|
|
32666
|
+
message: "Nickname contains unsupported characters (control characters are not allowed)."
|
|
32667
|
+
};
|
|
32668
|
+
}
|
|
32669
|
+
const trimmed = input.trim();
|
|
32670
|
+
if (!trimmed) {
|
|
32671
|
+
return { ok: false, error: "empty", message: "Nickname is empty." };
|
|
32672
|
+
}
|
|
32673
|
+
if ([...trimmed].length > NICKNAME_MAX_CODE_POINTS) {
|
|
32674
|
+
return {
|
|
32675
|
+
ok: false,
|
|
32676
|
+
error: "too_long",
|
|
32677
|
+
message: `Nickname is too long (max ${NICKNAME_MAX_CODE_POINTS} characters).`
|
|
32678
|
+
};
|
|
32679
|
+
}
|
|
32680
|
+
return { ok: true, value: trimmed };
|
|
32681
|
+
}
|
|
32682
|
+
|
|
32683
|
+
// src/commands/friends-nickname.ts
|
|
32684
|
+
function exitWithError3(e5, fallback2) {
|
|
32685
|
+
console.error(error(e5 instanceof Error ? e5.message : fallback2));
|
|
32686
|
+
process.exit(1);
|
|
32687
|
+
}
|
|
32688
|
+
function friendLabel(f) {
|
|
32689
|
+
const name = f.friend.name ?? f.friend.id;
|
|
32690
|
+
return f.friend.handle ? `${name} (@${f.friend.handle})` : name;
|
|
32691
|
+
}
|
|
32692
|
+
async function fetchUserFriends(auth) {
|
|
32693
|
+
const limit = 100;
|
|
32694
|
+
const all = [];
|
|
32695
|
+
for (let offset = 0;offset < 1e4; offset += limit) {
|
|
32696
|
+
const data = await amikoWebFetch(auth, "/api/friends", {
|
|
32697
|
+
query: { type: "user", limit, offset },
|
|
32698
|
+
timeoutMs: 20000
|
|
32699
|
+
});
|
|
32700
|
+
const page = data.friends ?? [];
|
|
32701
|
+
all.push(...page);
|
|
32702
|
+
if (page.length < limit || data.pagination?.has_more === false)
|
|
32703
|
+
break;
|
|
32704
|
+
}
|
|
32705
|
+
return all;
|
|
32706
|
+
}
|
|
32707
|
+
async function resolveFriend(auth, target) {
|
|
32708
|
+
const t = target.trim();
|
|
32709
|
+
if (!t) {
|
|
32710
|
+
throw new Error("No friend given. Pass a name, @handle, user id, or friendship id.");
|
|
32711
|
+
}
|
|
32712
|
+
const all = await fetchUserFriends(auth);
|
|
32713
|
+
const asId = normalizeUserId(t);
|
|
32714
|
+
const byId = all.find((f) => f.friendship_id === asId || f.friend.id === asId);
|
|
32715
|
+
if (byId)
|
|
32716
|
+
return byId;
|
|
32717
|
+
if (t.startsWith("@")) {
|
|
32718
|
+
const handle = t.slice(1).toLowerCase();
|
|
32719
|
+
const byHandle = all.filter((f) => f.friend.handle?.toLowerCase() === handle);
|
|
32720
|
+
if (byHandle.length === 1)
|
|
32721
|
+
return byHandle[0];
|
|
32722
|
+
throw new Error(`No friend ${t}. Nicknames only work for accepted friends — run \`amiko friends list\`.`);
|
|
32723
|
+
}
|
|
32724
|
+
const lower = t.toLowerCase();
|
|
32725
|
+
const exact = all.filter((f) => f.friend.name?.toLowerCase() === lower || f.friend.handle?.toLowerCase() === lower || f.nickname?.toLowerCase() === lower);
|
|
32726
|
+
const matches = exact.length ? exact : all.filter((f) => f.friend.name?.toLowerCase().includes(lower) || f.friend.handle?.toLowerCase().includes(lower) || f.nickname?.toLowerCase().includes(lower));
|
|
32727
|
+
if (matches.length === 0) {
|
|
32728
|
+
throw new Error(`No friend matching "${t}". Nicknames only work for accepted friends — run \`amiko friends list\`.`);
|
|
32729
|
+
}
|
|
32730
|
+
if (matches.length > 1) {
|
|
32731
|
+
const names = matches.map((f) => `${friendLabel(f)} (${f.friend.id})`).join(", ");
|
|
32732
|
+
throw new Error(`"${t}" is ambiguous — matches: ${names}. Use the friend's @handle or user id.`);
|
|
32733
|
+
}
|
|
32734
|
+
return matches[0];
|
|
32735
|
+
}
|
|
32736
|
+
async function resolveFriendOrExit(auth, target) {
|
|
32737
|
+
try {
|
|
32738
|
+
return await resolveFriend(auth, target);
|
|
32739
|
+
} catch (e5) {
|
|
32740
|
+
exitWithError3(e5, "Could not resolve the friend");
|
|
32741
|
+
}
|
|
32742
|
+
}
|
|
32743
|
+
async function patchNickname(auth, friendshipId, nickname, spinner) {
|
|
32744
|
+
try {
|
|
32745
|
+
const data = await amikoWebFetch(auth, `/api/friends/${encodeURIComponent(friendshipId)}/nickname`, { method: "PATCH", body: { nickname }, timeoutMs: 20000 });
|
|
32746
|
+
spinner?.stop();
|
|
32747
|
+
return data;
|
|
32748
|
+
} catch (e5) {
|
|
32749
|
+
spinner?.stop();
|
|
32750
|
+
if (e5.status === 403) {
|
|
32751
|
+
console.error(error(e5 instanceof Error ? e5.message : "Forbidden"));
|
|
32752
|
+
console.error(dim("Tip: nickname writes need an amiko-web deploy that allows twin tokens to manage nicknames (feat/friend-nicknames)."));
|
|
32753
|
+
process.exit(1);
|
|
32754
|
+
}
|
|
32755
|
+
exitWithError3(e5, "Failed to update the nickname");
|
|
31480
32756
|
}
|
|
31481
32757
|
}
|
|
32758
|
+
async function runList(opts) {
|
|
32759
|
+
const auth = authOrExit();
|
|
32760
|
+
const spinner = opts.json ? null : ora("Loading nicknames...").start();
|
|
32761
|
+
let all;
|
|
32762
|
+
try {
|
|
32763
|
+
all = await fetchUserFriends(auth);
|
|
32764
|
+
} catch (e5) {
|
|
32765
|
+
spinner?.stop();
|
|
32766
|
+
exitWithError3(e5, "Failed to load friends");
|
|
32767
|
+
}
|
|
32768
|
+
spinner?.stop();
|
|
32769
|
+
const named = all.filter((f) => !!f.nickname);
|
|
32770
|
+
renderOutput({
|
|
32771
|
+
nicknames: named.map((f) => ({
|
|
32772
|
+
nickname: f.nickname ?? null,
|
|
32773
|
+
user_id: f.friend.id,
|
|
32774
|
+
name: f.friend.name ?? null,
|
|
32775
|
+
handle: f.friend.handle ?? null,
|
|
32776
|
+
friendship_id: f.friendship_id
|
|
32777
|
+
}))
|
|
32778
|
+
}, () => {
|
|
32779
|
+
if (named.length === 0) {
|
|
32780
|
+
console.log(dim("No nicknames set."));
|
|
32781
|
+
console.log(dim('Set one with `amiko friends nickname set <friend> "<nickname>"`.'));
|
|
32782
|
+
return;
|
|
32783
|
+
}
|
|
32784
|
+
console.log(heading(`Nicknames (${named.length})`));
|
|
32785
|
+
const rows = [
|
|
32786
|
+
[
|
|
32787
|
+
dim("NICKNAME"),
|
|
32788
|
+
dim("NAME"),
|
|
32789
|
+
dim("HANDLE"),
|
|
32790
|
+
dim("USER ID"),
|
|
32791
|
+
dim("FRIENDSHIP")
|
|
32792
|
+
],
|
|
32793
|
+
...named.map((f) => [
|
|
32794
|
+
f.nickname ?? "-",
|
|
32795
|
+
f.friend.name ?? "-",
|
|
32796
|
+
f.friend.handle ?? "-",
|
|
32797
|
+
f.friend.id,
|
|
32798
|
+
f.friendship_id
|
|
32799
|
+
])
|
|
32800
|
+
];
|
|
32801
|
+
console.log(table(rows));
|
|
32802
|
+
}, { json: opts.json });
|
|
32803
|
+
}
|
|
32804
|
+
function registerFriendsNicknameCommand(nickname) {
|
|
32805
|
+
nickname.option("--json", "Output as JSON").action(runList);
|
|
32806
|
+
nickname.command("list").description("List every nickname you've set").option("--json", "Output as JSON").action((_opts, cmd) => runList(cmd.optsWithGlobals()));
|
|
32807
|
+
nickname.command("set <friend> <nickname>").description("Set or change your private nickname for a friend (name, @handle, user id, or friendship id)").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output as JSON").action(async (target, rawNickname, _opts, cmd) => {
|
|
32808
|
+
const opts = cmd.optsWithGlobals();
|
|
32809
|
+
const checked = validateNickname(rawNickname);
|
|
32810
|
+
if (!checked.ok) {
|
|
32811
|
+
console.error(error(checked.message));
|
|
32812
|
+
if (checked.error === "empty") {
|
|
32813
|
+
console.error(dim(`Tip: to clear a nickname, run \`amiko friends nickname remove ${JSON.stringify(target)}\`.`));
|
|
32814
|
+
}
|
|
32815
|
+
process.exit(1);
|
|
32816
|
+
}
|
|
32817
|
+
const auth = authOrExit();
|
|
32818
|
+
const friend = await resolveFriendOrExit(auth, target);
|
|
32819
|
+
await confirmDestructive({
|
|
32820
|
+
action: friend.nickname ? `Change your private nickname for ${friendLabel(friend)} from "${friend.nickname}" to "${checked.value}"` : `Set your private nickname for ${friendLabel(friend)} to "${checked.value}"`,
|
|
32821
|
+
detail: "Nicknames are private — only you ever see them, in every Amiko app.",
|
|
32822
|
+
yes: opts.yes,
|
|
32823
|
+
commandExample: `amiko friends nickname set ${JSON.stringify(target)} ${JSON.stringify(checked.value)}`
|
|
32824
|
+
});
|
|
32825
|
+
const spinner = opts.json ? null : ora("Saving nickname...").start();
|
|
32826
|
+
const data = await patchNickname(auth, friend.friendship_id, checked.value, spinner);
|
|
32827
|
+
renderOutput(data, (d) => console.log(success(`${friendLabel(friend)} is now "${d.nickname}" to you`)), { json: opts.json });
|
|
32828
|
+
});
|
|
32829
|
+
nickname.command("remove <friend>").description("Remove your private nickname for a friend").option("--yes", "Skip the confirmation (required in non-interactive shells)").option("--json", "Output as JSON").action(async (target, _opts, cmd) => {
|
|
32830
|
+
const opts = cmd.optsWithGlobals();
|
|
32831
|
+
const auth = authOrExit();
|
|
32832
|
+
const friend = await resolveFriendOrExit(auth, target);
|
|
32833
|
+
if (!friend.nickname) {
|
|
32834
|
+
renderOutput({ nickname: null }, () => console.log(dim(`No nickname set for ${friendLabel(friend)}.`)), { json: opts.json });
|
|
32835
|
+
return;
|
|
32836
|
+
}
|
|
32837
|
+
await confirmDestructive({
|
|
32838
|
+
action: `Remove your private nickname "${friend.nickname}" for ${friendLabel(friend)}`,
|
|
32839
|
+
yes: opts.yes,
|
|
32840
|
+
commandExample: `amiko friends nickname remove ${JSON.stringify(target)}`
|
|
32841
|
+
});
|
|
32842
|
+
const spinner = opts.json ? null : ora("Removing nickname...").start();
|
|
32843
|
+
const data = await patchNickname(auth, friend.friendship_id, null, spinner);
|
|
32844
|
+
renderOutput(data, () => console.log(success(`Nickname removed — ${friendLabel(friend)} shows their real name again`)), { json: opts.json });
|
|
32845
|
+
});
|
|
32846
|
+
}
|
|
31482
32847
|
|
|
31483
32848
|
// src/commands/friends.ts
|
|
31484
32849
|
async function runPaidRegenerate(args) {
|
|
@@ -31603,11 +32968,12 @@ function registerFriendsCommand(program2) {
|
|
|
31603
32968
|
console.log(dim("No friends."));
|
|
31604
32969
|
return;
|
|
31605
32970
|
}
|
|
31606
|
-
console.log(heading(`Friends (${items.length})`));
|
|
32971
|
+
console.log(heading(`Friends (${d.pagination?.total ?? items.length})`));
|
|
31607
32972
|
const rows = [
|
|
31608
32973
|
[
|
|
31609
32974
|
dim("USER ID"),
|
|
31610
32975
|
dim("NAME"),
|
|
32976
|
+
dim("NICKNAME"),
|
|
31611
32977
|
dim("HANDLE"),
|
|
31612
32978
|
dim("TYPE"),
|
|
31613
32979
|
dim("FRIENDSHIP"),
|
|
@@ -31616,6 +32982,7 @@ function registerFriendsCommand(program2) {
|
|
|
31616
32982
|
...items.map((f) => [
|
|
31617
32983
|
f.friend.id,
|
|
31618
32984
|
f.friend.name ?? "-",
|
|
32985
|
+
f.nickname ?? "-",
|
|
31619
32986
|
f.friend.handle ?? "-",
|
|
31620
32987
|
f.friend.type,
|
|
31621
32988
|
f.friendship_id,
|
|
@@ -31720,6 +33087,8 @@ Accept with: amiko friends accept <friendshipId>`));
|
|
|
31720
33087
|
process.exit(1);
|
|
31721
33088
|
}
|
|
31722
33089
|
});
|
|
33090
|
+
const nickname = program2.command("nickname").description("Private nicknames for friends — set, remove, list (only you ever see them)");
|
|
33091
|
+
registerFriendsNicknameCommand(nickname);
|
|
31723
33092
|
const reports = program2.command("reports").description("Friend matching reports");
|
|
31724
33093
|
reports.command("list").description("List friend matching reports").option("--page <n>", "Page number", "1").option("--limit <n>", "Page size (1-50)", "20").option("--json", "Output as JSON").action(async (opts) => {
|
|
31725
33094
|
const auth = resolveAuth();
|
|
@@ -32063,20 +33432,62 @@ The other user will be notified and must consent before the report is generated.
|
|
|
32063
33432
|
});
|
|
32064
33433
|
}
|
|
32065
33434
|
|
|
33435
|
+
// src/lib/paginate.ts
|
|
33436
|
+
var defaultFetcher = (auth, path2, opts) => amikoWebFetch(auth, path2, opts);
|
|
33437
|
+
var DEFAULT_PAGE_CAP = 2000;
|
|
33438
|
+
async function fetchAllCursor(auth, path2, opts) {
|
|
33439
|
+
const pageSize = opts.pageSize ?? 100;
|
|
33440
|
+
const cap = opts.cap ?? DEFAULT_PAGE_CAP;
|
|
33441
|
+
const fetcher = opts.fetcher ?? defaultFetcher;
|
|
33442
|
+
const items = [];
|
|
33443
|
+
let cursor;
|
|
33444
|
+
for (let i = 0;i < 1e4; i += 1) {
|
|
33445
|
+
const data = await fetcher(auth, path2, {
|
|
33446
|
+
query: { ...opts.query, limit: pageSize, cursor },
|
|
33447
|
+
timeoutMs: opts.timeoutMs
|
|
33448
|
+
});
|
|
33449
|
+
const page = data[opts.itemsKey] ?? [];
|
|
33450
|
+
items.push(...page);
|
|
33451
|
+
if (!data.hasMore || !data.nextCursor) {
|
|
33452
|
+
return { items, partial: false };
|
|
33453
|
+
}
|
|
33454
|
+
if (items.length >= cap) {
|
|
33455
|
+
return { items: items.slice(0, cap), partial: true };
|
|
33456
|
+
}
|
|
33457
|
+
cursor = data.nextCursor;
|
|
33458
|
+
}
|
|
33459
|
+
return { items: items.slice(0, cap), partial: true };
|
|
33460
|
+
}
|
|
33461
|
+
|
|
32066
33462
|
// src/commands/users.ts
|
|
33463
|
+
var SEARCH_ALL_CAP = 200;
|
|
33464
|
+
async function searchOnePage(auth, query, limit, cursor) {
|
|
33465
|
+
const data = await amikoWebFetch(auth, "/api/search", {
|
|
33466
|
+
query: { q: query, type: "people", limit, cursor }
|
|
33467
|
+
});
|
|
33468
|
+
return { ...data, partial: false };
|
|
33469
|
+
}
|
|
33470
|
+
async function searchAll(auth, query) {
|
|
33471
|
+
const { items, partial: partial3 } = await fetchAllCursor(auth, "/api/search", {
|
|
33472
|
+
itemsKey: "users",
|
|
33473
|
+
query: { q: query, type: "people" },
|
|
33474
|
+
pageSize: 20,
|
|
33475
|
+
cap: SEARCH_ALL_CAP
|
|
33476
|
+
});
|
|
33477
|
+
return { users: items, hasMore: false, nextCursor: null, partial: partial3 };
|
|
33478
|
+
}
|
|
33479
|
+
function printFollowState(state) {
|
|
33480
|
+
console.log(label("Following them", state.following ? "yes" : "no"));
|
|
33481
|
+
console.log(label("Follows you", state.followsMe ? "yes" : "no"));
|
|
33482
|
+
console.log(label("Followers", String(state.followerCount ?? 0)));
|
|
33483
|
+
console.log(label("Following", String(state.followingCount ?? 0)));
|
|
33484
|
+
}
|
|
32067
33485
|
function registerUsersCommand(program2) {
|
|
32068
|
-
program2.command("search <query>").description("Search for users by name or handle").option("--limit <n>", "Max results (1-20)", "10").option("--cursor <cursor>", "Pagination cursor").option("--json", "Output as JSON").action(async (query, opts) => {
|
|
33486
|
+
program2.command("search <query>").description("Search for users by name or handle").option("--limit <n>", "Max results (1-20)", "10").option("--all", 'Page through EVERY match (up to a safety cap) — use for "how many people match X". Ignores --limit').option("--cursor <cursor>", "Pagination cursor").option("--json", "Output as JSON").action(async (query, opts) => {
|
|
32069
33487
|
const auth = resolveAuth();
|
|
32070
33488
|
const spinner = opts.json ? null : ora("Searching users...").start();
|
|
32071
33489
|
try {
|
|
32072
|
-
const data = await
|
|
32073
|
-
query: {
|
|
32074
|
-
q: query,
|
|
32075
|
-
type: "people",
|
|
32076
|
-
limit: opts.limit,
|
|
32077
|
-
cursor: opts.cursor
|
|
32078
|
-
}
|
|
32079
|
-
});
|
|
33490
|
+
const data = opts.all ? await searchAll(auth, query) : await searchOnePage(auth, query, opts.limit, opts.cursor);
|
|
32080
33491
|
spinner?.stop();
|
|
32081
33492
|
renderOutput(data, (d) => {
|
|
32082
33493
|
const items = d.users ?? [];
|
|
@@ -32084,19 +33495,18 @@ function registerUsersCommand(program2) {
|
|
|
32084
33495
|
console.log(dim("No users found."));
|
|
32085
33496
|
return;
|
|
32086
33497
|
}
|
|
32087
|
-
console.log(heading(`Users (${items.length})`));
|
|
33498
|
+
console.log(heading(`Users (${items.length}${d.partial ? "+" : ""})`));
|
|
32088
33499
|
const rows = [
|
|
32089
33500
|
[dim("ID"), dim("NAME"), dim("HANDLE")],
|
|
32090
|
-
...items.map((u) => [
|
|
32091
|
-
u.id,
|
|
32092
|
-
u.name ?? "-",
|
|
32093
|
-
u.handle ?? "-"
|
|
32094
|
-
])
|
|
33501
|
+
...items.map((u) => [u.id, u.name ?? "-", u.handle ?? "-"])
|
|
32095
33502
|
];
|
|
32096
33503
|
console.log(table(rows));
|
|
32097
|
-
if (d.
|
|
33504
|
+
if (d.partial) {
|
|
32098
33505
|
console.log(dim(`
|
|
32099
|
-
|
|
33506
|
+
⚠ Partial results: stopped at the ${items.length}-match cap; more exist. Not the full set/count.`));
|
|
33507
|
+
} else if (d.hasMore && d.nextCursor) {
|
|
33508
|
+
console.log(dim(`
|
|
33509
|
+
More matches exist. Next page: --cursor ${d.nextCursor} · every match: --all`));
|
|
32100
33510
|
}
|
|
32101
33511
|
}, { json: opts.json });
|
|
32102
33512
|
} catch (e5) {
|
|
@@ -32140,6 +33550,97 @@ Twins (${u.twins.length})`));
|
|
|
32140
33550
|
process.exit(1);
|
|
32141
33551
|
}
|
|
32142
33552
|
});
|
|
33553
|
+
program2.command("follow <handleOrId>").description("Follow a user (one-way, no approval needed — their posts appear in `amiko feed --type following`)").option("--json", "Output as JSON").action(async (target, opts) => {
|
|
33554
|
+
const auth = resolveAuth();
|
|
33555
|
+
const spinner = opts.json ? null : ora(`Following ${target}...`).start();
|
|
33556
|
+
try {
|
|
33557
|
+
const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`, { method: "POST" });
|
|
33558
|
+
spinner?.stop();
|
|
33559
|
+
renderOutput(data, (d) => {
|
|
33560
|
+
console.log(success(`Now following ${target}`));
|
|
33561
|
+
printFollowState(d);
|
|
33562
|
+
}, { json: opts.json });
|
|
33563
|
+
} catch (e5) {
|
|
33564
|
+
spinner?.stop();
|
|
33565
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
33566
|
+
process.exit(1);
|
|
33567
|
+
}
|
|
33568
|
+
});
|
|
33569
|
+
program2.command("unfollow <handleOrId>").description("Stop following a user").option("--json", "Output as JSON").action(async (target, opts) => {
|
|
33570
|
+
const auth = resolveAuth();
|
|
33571
|
+
const spinner = opts.json ? null : ora(`Unfollowing ${target}...`).start();
|
|
33572
|
+
try {
|
|
33573
|
+
const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`, { method: "DELETE" });
|
|
33574
|
+
spinner?.stop();
|
|
33575
|
+
renderOutput(data, (d) => {
|
|
33576
|
+
console.log(success(`Unfollowed ${target}`));
|
|
33577
|
+
printFollowState(d);
|
|
33578
|
+
}, { json: opts.json });
|
|
33579
|
+
} catch (e5) {
|
|
33580
|
+
spinner?.stop();
|
|
33581
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
33582
|
+
process.exit(1);
|
|
33583
|
+
}
|
|
33584
|
+
});
|
|
33585
|
+
program2.command("follow-status <handleOrId>").description("Check whether you follow a user (and whether they follow you)").option("--json", "Output as JSON").action(async (target, opts) => {
|
|
33586
|
+
const auth = resolveAuth();
|
|
33587
|
+
const spinner = opts.json ? null : ora(`Checking follow status for ${target}...`).start();
|
|
33588
|
+
try {
|
|
33589
|
+
const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/follow`);
|
|
33590
|
+
spinner?.stop();
|
|
33591
|
+
renderOutput(data, (d) => {
|
|
33592
|
+
console.log(heading(target));
|
|
33593
|
+
printFollowState(d);
|
|
33594
|
+
}, { json: opts.json });
|
|
33595
|
+
} catch (e5) {
|
|
33596
|
+
spinner?.stop();
|
|
33597
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
33598
|
+
process.exit(1);
|
|
33599
|
+
}
|
|
33600
|
+
});
|
|
33601
|
+
for (const kind of ["followers", "following"]) {
|
|
33602
|
+
program2.command(`${kind} <handleOrId>`).description(kind === "followers" ? "List who follows a user (newest first)" : "List who a user follows (newest first)").option("--limit <n>", "Max results (1-50, default 20)", "20").option("--cursor <cursor>", "Pagination cursor").option("--json", "Output as JSON").action(async (target, opts) => {
|
|
33603
|
+
const limit = Number(opts.limit ?? "20");
|
|
33604
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 50) {
|
|
33605
|
+
console.error(error("--limit must be an integer between 1 and 50"));
|
|
33606
|
+
process.exit(1);
|
|
33607
|
+
}
|
|
33608
|
+
const auth = resolveAuth();
|
|
33609
|
+
const spinner = opts.json ? null : ora(`Loading ${kind}...`).start();
|
|
33610
|
+
try {
|
|
33611
|
+
const data = await amikoWebFetch(auth, `/api/users/${encodeURIComponent(target)}/${kind}`, {
|
|
33612
|
+
query: { limit, cursor: opts.cursor }
|
|
33613
|
+
});
|
|
33614
|
+
spinner?.stop();
|
|
33615
|
+
const items = data.items ?? [];
|
|
33616
|
+
renderOutput(data, (d) => {
|
|
33617
|
+
if (items.length === 0) {
|
|
33618
|
+
console.log(dim(`No ${kind}.`));
|
|
33619
|
+
return;
|
|
33620
|
+
}
|
|
33621
|
+
console.log(heading(`${target} — ${kind} (${items.length}${d.nextCursor ? "+" : ""})`));
|
|
33622
|
+
const rows = [
|
|
33623
|
+
[dim("ID"), dim("NAME"), dim("HANDLE"), dim("YOU FOLLOW")],
|
|
33624
|
+
...items.map((u) => [
|
|
33625
|
+
u.id,
|
|
33626
|
+
u.name ?? "-",
|
|
33627
|
+
u.handle ?? "-",
|
|
33628
|
+
u.followedByMe ? "yes" : "no"
|
|
33629
|
+
])
|
|
33630
|
+
];
|
|
33631
|
+
console.log(table(rows));
|
|
33632
|
+
if (d.nextCursor) {
|
|
33633
|
+
console.log(dim(`
|
|
33634
|
+
Next: --cursor ${d.nextCursor}`));
|
|
33635
|
+
}
|
|
33636
|
+
}, { json: opts.json });
|
|
33637
|
+
} catch (e5) {
|
|
33638
|
+
spinner?.stop();
|
|
33639
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
33640
|
+
process.exit(1);
|
|
33641
|
+
}
|
|
33642
|
+
});
|
|
33643
|
+
}
|
|
32143
33644
|
}
|
|
32144
33645
|
|
|
32145
33646
|
// src/lib/post-media.ts
|
|
@@ -32174,17 +33675,66 @@ function isAmikoMediaUrl(value) {
|
|
|
32174
33675
|
const parsed = new URL(value);
|
|
32175
33676
|
return parsed.protocol === "https:" && parsed.pathname.startsWith(AMIKO_PUBLIC_STORAGE_PATH);
|
|
32176
33677
|
}
|
|
32177
|
-
|
|
33678
|
+
var DRIVE_MEDIA_REF = /^drive:(.+)$/i;
|
|
33679
|
+
function isFeedMediaMime(mime) {
|
|
33680
|
+
return mime.startsWith("image/") || mime.startsWith("audio/") || mime.startsWith("video/");
|
|
33681
|
+
}
|
|
33682
|
+
var MEDIA_EXTENSIONS = new Set([
|
|
33683
|
+
"png",
|
|
33684
|
+
"jpg",
|
|
33685
|
+
"jpeg",
|
|
33686
|
+
"webp",
|
|
33687
|
+
"gif",
|
|
33688
|
+
"avif",
|
|
33689
|
+
"heic",
|
|
33690
|
+
"svg",
|
|
33691
|
+
"mp3",
|
|
33692
|
+
"m4a",
|
|
33693
|
+
"aac",
|
|
33694
|
+
"wav",
|
|
33695
|
+
"flac",
|
|
33696
|
+
"ogg",
|
|
33697
|
+
"opus",
|
|
33698
|
+
"mp4",
|
|
33699
|
+
"mov",
|
|
33700
|
+
"webm",
|
|
33701
|
+
"m4v",
|
|
33702
|
+
"mkv"
|
|
33703
|
+
]);
|
|
33704
|
+
function looksLikeMediaFilename(name) {
|
|
33705
|
+
const ext = name.split(".").pop()?.toLowerCase() ?? "";
|
|
33706
|
+
return MEDIA_EXTENSIONS.has(ext);
|
|
33707
|
+
}
|
|
33708
|
+
async function resolveDriveMedia(auth, docId, twinFlag) {
|
|
33709
|
+
const twinId = resolveTwinId({ flag: twinFlag, config: loadConfig(), auth });
|
|
33710
|
+
const { doc: doc2 } = await amikoWebFetch(auth, `/api/agents/${twinId}/docs/${encodeURIComponent(docId)}`);
|
|
33711
|
+
if (!doc2?.file_url) {
|
|
33712
|
+
throw new Error(`Drive file "${docId}" not found in twin ${twinId}'s Drive (or it has no stored URL). Get a valid id from \`amiko drive list --json\` (the \`id\` field) for that same twin.`);
|
|
33713
|
+
}
|
|
33714
|
+
const mime = (doc2.file_type ?? "").toLowerCase();
|
|
33715
|
+
const filename = doc2.filename ?? "file";
|
|
33716
|
+
if (mime && !isFeedMediaMime(mime) && !looksLikeMediaFilename(filename)) {
|
|
33717
|
+
throw new Error(`Drive file "${docId}" is ${mime} (${filename}), not an image/audio/video. Attach documents with --doc, not --media.`);
|
|
33718
|
+
}
|
|
33719
|
+
if (!isAmikoMediaUrl(doc2.file_url)) {
|
|
33720
|
+
throw new Error(`Drive file "${docId}" (${filename}) is stored at a URL that isn't Amiko public storage (${doc2.file_url}), so it can't be attached to a post. Re-upload it with \`amiko drive upload\`, or pass the media's local file path to --media.`);
|
|
33721
|
+
}
|
|
33722
|
+
return doc2.file_url;
|
|
33723
|
+
}
|
|
33724
|
+
async function resolveMediaUrls(auth, inputs, opts = {}) {
|
|
32178
33725
|
const resolved = [];
|
|
32179
33726
|
for (const value of inputs) {
|
|
32180
33727
|
const trimmed = value.trim();
|
|
32181
33728
|
if (trimmed.length === 0)
|
|
32182
33729
|
continue;
|
|
32183
|
-
|
|
33730
|
+
const driveRef = trimmed.match(DRIVE_MEDIA_REF);
|
|
33731
|
+
if (driveRef) {
|
|
33732
|
+
resolved.push(await resolveDriveMedia(auth, driveRef[1].trim(), opts.twin));
|
|
33733
|
+
} else if (/^https?:\/\//i.test(trimmed)) {
|
|
32184
33734
|
if (isAmikoMediaUrl(trimmed)) {
|
|
32185
33735
|
resolved.push(trimmed);
|
|
32186
33736
|
} else {
|
|
32187
|
-
throw new Error(`"${trimmed}" is not an
|
|
33737
|
+
throw new Error(`"${trimmed}" is not an attachable media source. Use one of: a local IMAGE file path; an Amiko public storage URL (e.g. an image/music/video URL from \`amiko create\`); or \`drive:<docId>\` for a file already in your Drive (\`amiko drive list --json\`). A signed download URL or a /d/<slug> share link won't work — pass \`drive:<docId>\` instead.`);
|
|
32188
33738
|
}
|
|
32189
33739
|
} else {
|
|
32190
33740
|
resolved.push(await uploadPostMedia(auth, trimmed));
|
|
@@ -32193,15 +33743,167 @@ async function resolveMediaUrls(auth, inputs) {
|
|
|
32193
33743
|
return resolved;
|
|
32194
33744
|
}
|
|
32195
33745
|
|
|
33746
|
+
// src/lib/post-doc.ts
|
|
33747
|
+
var MAX_DOC_ATTACHMENTS = 8;
|
|
33748
|
+
var MULTIPART_MAX_BYTES = 4 * 1024 * 1024;
|
|
33749
|
+
var MAX_DOC_BYTES = 50 * 1024 * 1024;
|
|
33750
|
+
var SIGNED_PUT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
33751
|
+
function isMediaMime(mime) {
|
|
33752
|
+
return mime.startsWith("image/") || mime.startsWith("video/") || mime.startsWith("audio/");
|
|
33753
|
+
}
|
|
33754
|
+
async function uploadPostDoc(auth, source) {
|
|
33755
|
+
const file2 = await resolveFileInput(source, { maxBytes: MAX_DOC_BYTES });
|
|
33756
|
+
if (isMediaMime(file2.mime)) {
|
|
33757
|
+
throw new Error(`"${source}" is a media file (${file2.mime}). Attach images/audio/video with --media, not --doc.`);
|
|
33758
|
+
}
|
|
33759
|
+
if (file2.size > MAX_DOC_BYTES) {
|
|
33760
|
+
throw new Error(`"${source}" is ${file2.size} bytes; exceeds the 50 MB document limit.`);
|
|
33761
|
+
}
|
|
33762
|
+
if (file2.size <= MULTIPART_MAX_BYTES) {
|
|
33763
|
+
const form = new FormData;
|
|
33764
|
+
form.set("file", new Blob([new Uint8Array(file2.buffer)], { type: file2.mime }), file2.name);
|
|
33765
|
+
const res = await amikoWebFetch(auth, "/api/upload/post-doc", {
|
|
33766
|
+
method: "POST",
|
|
33767
|
+
multipart: form,
|
|
33768
|
+
timeoutMs: 120000
|
|
33769
|
+
});
|
|
33770
|
+
if (!res.url) {
|
|
33771
|
+
throw new Error(res.error ?? "post-doc upload returned no URL.");
|
|
33772
|
+
}
|
|
33773
|
+
return {
|
|
33774
|
+
url: res.url,
|
|
33775
|
+
filename: res.filename ?? file2.name,
|
|
33776
|
+
mime_type: res.mime_type ?? file2.mime,
|
|
33777
|
+
size: res.size ?? file2.size
|
|
33778
|
+
};
|
|
33779
|
+
}
|
|
33780
|
+
const signed = await amikoWebFetch(auth, "/api/upload/post-doc/sign", {
|
|
33781
|
+
method: "POST",
|
|
33782
|
+
body: { filename: file2.name, contentType: file2.mime, size: file2.size }
|
|
33783
|
+
});
|
|
33784
|
+
if (!signed.signedUrl || !signed.url) {
|
|
33785
|
+
throw new Error(signed.error ?? "Could not prepare the document upload.");
|
|
33786
|
+
}
|
|
33787
|
+
const put = await fetch(signed.signedUrl, {
|
|
33788
|
+
method: "PUT",
|
|
33789
|
+
headers: { "content-type": file2.mime },
|
|
33790
|
+
body: new Uint8Array(file2.buffer),
|
|
33791
|
+
signal: AbortSignal.timeout(SIGNED_PUT_TIMEOUT_MS)
|
|
33792
|
+
});
|
|
33793
|
+
if (!put.ok) {
|
|
33794
|
+
const detail = await put.text().catch(() => "");
|
|
33795
|
+
throw new Error(`Direct storage upload failed: ${put.status} ${put.statusText}${detail ? ` — ${detail.slice(0, 200)}` : ""}`);
|
|
33796
|
+
}
|
|
33797
|
+
return {
|
|
33798
|
+
url: signed.url,
|
|
33799
|
+
filename: file2.name,
|
|
33800
|
+
mime_type: file2.mime,
|
|
33801
|
+
size: file2.size
|
|
33802
|
+
};
|
|
33803
|
+
}
|
|
33804
|
+
function deriveFilename(url2) {
|
|
33805
|
+
try {
|
|
33806
|
+
const path2 = new URL(url2, "https://x").pathname;
|
|
33807
|
+
return decodeURIComponent(path2.split("/").pop() || "") || "file";
|
|
33808
|
+
} catch {
|
|
33809
|
+
return "file";
|
|
33810
|
+
}
|
|
33811
|
+
}
|
|
33812
|
+
async function resolveDocAttachments(auth, inputs) {
|
|
33813
|
+
const values = inputs.map((v) => v.trim()).filter((v) => v.length > 0);
|
|
33814
|
+
if (values.length > MAX_DOC_ATTACHMENTS) {
|
|
33815
|
+
throw new Error(`Too many documents: ${values.length}. A post accepts at most ${MAX_DOC_ATTACHMENTS}.`);
|
|
33816
|
+
}
|
|
33817
|
+
const resolved = [];
|
|
33818
|
+
for (const value of values) {
|
|
33819
|
+
if (/^https?:\/\//i.test(value)) {
|
|
33820
|
+
if (!/^https:\/\//i.test(value)) {
|
|
33821
|
+
throw new Error(`"${value}" must be an https URL — plain http documents are not accepted.`);
|
|
33822
|
+
}
|
|
33823
|
+
resolved.push({ url: value, filename: deriveFilename(value) });
|
|
33824
|
+
} else {
|
|
33825
|
+
resolved.push(await uploadPostDoc(auth, value));
|
|
33826
|
+
}
|
|
33827
|
+
}
|
|
33828
|
+
return resolved;
|
|
33829
|
+
}
|
|
33830
|
+
|
|
32196
33831
|
// src/commands/feed.ts
|
|
33832
|
+
var MAX_TITLE_CHARS = 150;
|
|
33833
|
+
var graphemeSegmenter = new Intl.Segmenter(undefined, {
|
|
33834
|
+
granularity: "grapheme"
|
|
33835
|
+
});
|
|
33836
|
+
function graphemeLength(text) {
|
|
33837
|
+
let n = 0;
|
|
33838
|
+
for (const _ of graphemeSegmenter.segment(text))
|
|
33839
|
+
n++;
|
|
33840
|
+
return n;
|
|
33841
|
+
}
|
|
33842
|
+
var FEED_TYPES = [
|
|
33843
|
+
"all",
|
|
33844
|
+
"for_you",
|
|
33845
|
+
"friends",
|
|
33846
|
+
"following",
|
|
33847
|
+
"media",
|
|
33848
|
+
"humans",
|
|
33849
|
+
"amikos"
|
|
33850
|
+
];
|
|
33851
|
+
var MEDIA_KINDS = ["image", "audio", "video"];
|
|
33852
|
+
async function runMediaFeed(auth, opts) {
|
|
33853
|
+
const spinner = opts.json ? null : ora("Loading media feed...").start();
|
|
33854
|
+
try {
|
|
33855
|
+
const data = await amikoWebFetch(auth, "/api/media/feed", {
|
|
33856
|
+
query: { type: opts.kind, limit: opts.limit, cursor: opts.cursor }
|
|
33857
|
+
});
|
|
33858
|
+
spinner?.stop();
|
|
33859
|
+
const items = data.items ?? [];
|
|
33860
|
+
renderOutput({ ...data, items }, (d) => {
|
|
33861
|
+
if (items.length === 0) {
|
|
33862
|
+
console.log(dim("No public media."));
|
|
33863
|
+
return;
|
|
33864
|
+
}
|
|
33865
|
+
console.log(heading(`Media feed${opts.kind ? ` (${opts.kind})` : ""} — ${items.length} items`));
|
|
33866
|
+
for (const m of items) {
|
|
33867
|
+
const author = m.twin?.name ?? m.user?.name ?? m.user?.handle ?? "unknown";
|
|
33868
|
+
console.log("");
|
|
33869
|
+
console.log(label(author, m.created_at?.slice(0, 16) ?? ""));
|
|
33870
|
+
if (m.prompt)
|
|
33871
|
+
console.log(m.prompt);
|
|
33872
|
+
console.log(dim(`${m.mime_type}${m.model ? ` · ${m.model}` : ""}`));
|
|
33873
|
+
console.log(m.url);
|
|
33874
|
+
}
|
|
33875
|
+
if (d.nextCursor) {
|
|
33876
|
+
console.log("");
|
|
33877
|
+
console.log(dim(`Next: --cursor ${d.nextCursor}`));
|
|
33878
|
+
}
|
|
33879
|
+
}, { json: opts.json });
|
|
33880
|
+
} catch (e5) {
|
|
33881
|
+
spinner?.stop();
|
|
33882
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
33883
|
+
process.exit(1);
|
|
33884
|
+
}
|
|
33885
|
+
}
|
|
32197
33886
|
function registerFeedCommand(program2) {
|
|
32198
|
-
program2.command("feed").description("Get feed posts").option("--type <
|
|
33887
|
+
program2.command("feed").description("Get feed posts (notes)").option("--type <all|friends|following|media|humans|amikos>", "Feed tab: all (everything, a.k.a. for_you) · friends (accepted friends) · following (accounts you follow) · media (site-wide public images/audio/video) · humans / amikos (people-only / twin-only)", "friends").option("--kind <image|audio|video>", "Narrow `--type media` to one kind. Ignored by the post tabs").option("--hashtag <tag>", "Filter by hashtag").option("--limit <n>", "Max results (default 10, max 100)").option("--cursor <id>", "Pagination cursor").option("--unread", "Only return posts not yet read. Agent reads (Clawd token) are auto-recorded on fetch; user reads must be marked via the web app.").option("--json", "Output as JSON").action(async (opts) => {
|
|
33888
|
+
if (opts.type && !FEED_TYPES.includes(opts.type)) {
|
|
33889
|
+
console.error(error(`--type must be one of: ${FEED_TYPES.join(", ")}`));
|
|
33890
|
+
process.exit(1);
|
|
33891
|
+
}
|
|
33892
|
+
if (opts.kind && !MEDIA_KINDS.includes(opts.kind)) {
|
|
33893
|
+
console.error(error(`--kind must be one of: ${MEDIA_KINDS.join(", ")}`));
|
|
33894
|
+
process.exit(1);
|
|
33895
|
+
}
|
|
33896
|
+
const type = opts.type === "all" ? "for_you" : opts.type;
|
|
32199
33897
|
const auth = resolveAuth();
|
|
33898
|
+
if (type === "media") {
|
|
33899
|
+
await runMediaFeed(auth, opts);
|
|
33900
|
+
return;
|
|
33901
|
+
}
|
|
32200
33902
|
const spinner = opts.json ? null : ora("Loading feed...").start();
|
|
32201
33903
|
try {
|
|
32202
33904
|
const data = await amikoWebFetch(auth, "/api/feed", {
|
|
32203
33905
|
query: {
|
|
32204
|
-
type
|
|
33906
|
+
type,
|
|
32205
33907
|
hashtag: opts.hashtag,
|
|
32206
33908
|
limit: opts.limit,
|
|
32207
33909
|
cursor: opts.cursor,
|
|
@@ -32215,13 +33917,16 @@ function registerFeedCommand(program2) {
|
|
|
32215
33917
|
console.log(dim(opts.unread ? "No unread posts." : "No posts."));
|
|
32216
33918
|
return;
|
|
32217
33919
|
}
|
|
32218
|
-
const scope = `${
|
|
33920
|
+
const scope = `${type ?? "friends"}${opts.unread ? ", unread" : ""}`;
|
|
32219
33921
|
console.log(heading(`Feed (${scope}) — ${posts.length} posts`));
|
|
32220
33922
|
for (const p of posts) {
|
|
32221
33923
|
const author = p.twin?.name ?? p.user?.name ?? p.user?.handle ?? "unknown";
|
|
32222
33924
|
console.log("");
|
|
32223
33925
|
console.log(label(author, p.created_at?.slice(0, 16) ?? ""));
|
|
32224
|
-
|
|
33926
|
+
if (p.title)
|
|
33927
|
+
console.log(heading(p.title));
|
|
33928
|
+
if (p.content)
|
|
33929
|
+
console.log(p.content);
|
|
32225
33930
|
if (p._count) {
|
|
32226
33931
|
console.log(dim(`${p._count.likes ?? 0} likes · ${p._count.comments ?? 0} comments · ${p._count.bookmarks ?? 0} bookmarks`));
|
|
32227
33932
|
}
|
|
@@ -32240,32 +33945,123 @@ function registerFeedCommand(program2) {
|
|
|
32240
33945
|
});
|
|
32241
33946
|
}
|
|
32242
33947
|
function registerPostCommand(program2) {
|
|
32243
|
-
program2.command("create").description("Create a post on your feed").
|
|
33948
|
+
program2.command("create").description("Create a post (a.k.a. note) on your feed").option("--content <text>", "Post body. Optional when --media or --doc is given (an image-only note is a normal post)").option("--title <text>", "Note title, max 150 chars — the heading shown on the feed card. Optional but strongly recommended for image notes").option("--visibility <public|private>", "Post visibility", "public").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically); Amiko-hosted URLs (image/music/video from `amiko create`); or `drive:<docId>` for a media file in your Drive (`amiko drive list`)").option("--doc <pathOrUrl...>", "Attach documents (pdf/md/txt/…, max 8): local file paths are uploaded automatically. Rendered as file cards, not images").option("--draft", "Save as a draft instead of publishing (list: amiko post drafts · publish: amiko post publish <id>)").option("--json", "Output as JSON").action(async (opts) => {
|
|
32244
33949
|
const auth = resolveAuth();
|
|
32245
33950
|
const visibility = opts.visibility === "private" ? "private" : "public";
|
|
32246
|
-
const
|
|
33951
|
+
const content = opts.content?.trim() ?? "";
|
|
33952
|
+
const title = opts.title?.trim() ?? "";
|
|
33953
|
+
if (!content && !opts.media?.length && !opts.doc?.length) {
|
|
33954
|
+
console.error(error("Nothing to post. Pass --content, and/or attach something with --media / --doc."));
|
|
33955
|
+
process.exit(1);
|
|
33956
|
+
}
|
|
33957
|
+
const titleLength = graphemeLength(title);
|
|
33958
|
+
if (titleLength > MAX_TITLE_CHARS) {
|
|
33959
|
+
console.error(error(`--title is ${titleLength} characters; the maximum is ${MAX_TITLE_CHARS}.`));
|
|
33960
|
+
process.exit(1);
|
|
33961
|
+
}
|
|
33962
|
+
const workingText = opts.draft ? "Saving draft..." : "Publishing post...";
|
|
33963
|
+
const spinner = opts.json ? null : ora(workingText).start();
|
|
32247
33964
|
try {
|
|
32248
33965
|
const body = {
|
|
32249
|
-
content
|
|
33966
|
+
content,
|
|
32250
33967
|
visibility
|
|
32251
33968
|
};
|
|
33969
|
+
if (title)
|
|
33970
|
+
body.title = title;
|
|
33971
|
+
if (opts.draft)
|
|
33972
|
+
body.status = "draft";
|
|
32252
33973
|
if (opts.media?.length) {
|
|
32253
33974
|
if (spinner)
|
|
32254
33975
|
spinner.text = "Uploading media...";
|
|
32255
33976
|
body.media_urls = await resolveMediaUrls(auth, opts.media);
|
|
32256
33977
|
if (spinner)
|
|
32257
|
-
spinner.text =
|
|
33978
|
+
spinner.text = workingText;
|
|
33979
|
+
}
|
|
33980
|
+
if (opts.doc?.length) {
|
|
33981
|
+
if (spinner)
|
|
33982
|
+
spinner.text = "Uploading documents...";
|
|
33983
|
+
body.doc_attachments = await resolveDocAttachments(auth, opts.doc);
|
|
33984
|
+
if (spinner)
|
|
33985
|
+
spinner.text = workingText;
|
|
32258
33986
|
}
|
|
32259
33987
|
const data = await amikoWebFetch(auth, "/api/posts", { method: "POST", body });
|
|
32260
33988
|
spinner?.stop();
|
|
32261
|
-
|
|
32262
|
-
|
|
33989
|
+
const postUrl = !opts.draft && data.post?.id ? `https://platform.heyamiko.com/post/${data.post.id}` : null;
|
|
33990
|
+
renderOutput(postUrl ? { ...data, post_url: postUrl } : data, (d) => {
|
|
33991
|
+
console.log(success(opts.draft ? "Draft saved" : "Post published"));
|
|
32263
33992
|
const post = d.post;
|
|
32264
33993
|
if (post) {
|
|
32265
33994
|
if (post.id)
|
|
32266
33995
|
console.log(label("Post ID", String(post.id)));
|
|
33996
|
+
if (postUrl)
|
|
33997
|
+
console.log(label("URL", postUrl));
|
|
33998
|
+
if (opts.draft)
|
|
33999
|
+
console.log(label("Status", "draft"));
|
|
34000
|
+
if (title)
|
|
34001
|
+
console.log(label("Title", title));
|
|
34002
|
+
if (opts.doc?.length) {
|
|
34003
|
+
const docs = body.doc_attachments;
|
|
34004
|
+
console.log(label("Documents", (docs ?? []).map((doc2) => doc2.filename).join(", ")));
|
|
34005
|
+
}
|
|
32267
34006
|
console.log(label("Visibility", visibility));
|
|
34007
|
+
if (opts.draft && post.id) {
|
|
34008
|
+
console.log(dim(`No public URL until published. List: amiko post drafts · Publish: amiko post publish ${post.id}`));
|
|
34009
|
+
}
|
|
34010
|
+
}
|
|
34011
|
+
}, { json: opts.json });
|
|
34012
|
+
} catch (e5) {
|
|
34013
|
+
spinner?.stop();
|
|
34014
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
34015
|
+
process.exit(1);
|
|
34016
|
+
}
|
|
34017
|
+
});
|
|
34018
|
+
program2.command("drafts").description("List your draft posts (newest first)").option("--limit <n>", "Max results (1-100, default 20)", "20").option("--offset <n>", "Pagination offset", "0").option("--json", "Output as JSON").action(async (opts) => {
|
|
34019
|
+
const limit = Number(opts.limit ?? "20");
|
|
34020
|
+
const offset = Number(opts.offset ?? "0");
|
|
34021
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
|
|
34022
|
+
console.error(error("--limit must be an integer between 1 and 100"));
|
|
34023
|
+
process.exit(1);
|
|
34024
|
+
}
|
|
34025
|
+
if (!Number.isInteger(offset) || offset < 0) {
|
|
34026
|
+
console.error(error("--offset must be a non-negative integer"));
|
|
34027
|
+
process.exit(1);
|
|
34028
|
+
}
|
|
34029
|
+
const auth = resolveAuth();
|
|
34030
|
+
const spinner = opts.json ? null : ora("Loading drafts...").start();
|
|
34031
|
+
try {
|
|
34032
|
+
const data = await amikoWebFetch(auth, "/api/posts", {
|
|
34033
|
+
query: {
|
|
34034
|
+
status: "draft",
|
|
34035
|
+
limit,
|
|
34036
|
+
offset
|
|
34037
|
+
}
|
|
34038
|
+
});
|
|
34039
|
+
spinner?.stop();
|
|
34040
|
+
const posts = data.posts ?? [];
|
|
34041
|
+
renderOutput(data, () => {
|
|
34042
|
+
if (posts.length === 0) {
|
|
34043
|
+
console.log(dim('No drafts. Save one with: amiko post create --content "..." --draft'));
|
|
34044
|
+
return;
|
|
34045
|
+
}
|
|
34046
|
+
console.log(heading(`Drafts — ${posts.length}`));
|
|
34047
|
+
for (const p of posts) {
|
|
34048
|
+
console.log("");
|
|
34049
|
+
console.log(label(p.twin?.name ?? "you", (p.updated_at ?? p.created_at)?.slice(0, 16) ?? ""));
|
|
34050
|
+
if (p.title)
|
|
34051
|
+
console.log(heading(p.title));
|
|
34052
|
+
if (p.content) {
|
|
34053
|
+
console.log(p.content.length > 120 ? `${p.content.slice(0, 120)}…` : p.content);
|
|
34054
|
+
}
|
|
34055
|
+
const mediaCount = p.media_urls?.length ?? 0;
|
|
34056
|
+
const docCount = p.doc_attachments?.length ?? 0;
|
|
34057
|
+
console.log(dim(`${p.visibility ?? "public"}${mediaCount ? ` · ${mediaCount} media` : ""}${docCount ? ` · ${docCount} docs` : ""}`));
|
|
34058
|
+
console.log(dim(`id: ${p.id}`));
|
|
34059
|
+
}
|
|
34060
|
+
console.log("");
|
|
34061
|
+
if (data.hasMore) {
|
|
34062
|
+
console.log(dim(`More available — pass --offset ${offset + posts.length}`));
|
|
32268
34063
|
}
|
|
34064
|
+
console.log(dim("Publish: amiko post publish <id>"));
|
|
32269
34065
|
}, { json: opts.json });
|
|
32270
34066
|
} catch (e5) {
|
|
32271
34067
|
spinner?.stop();
|
|
@@ -32273,6 +34069,34 @@ function registerPostCommand(program2) {
|
|
|
32273
34069
|
process.exit(1);
|
|
32274
34070
|
}
|
|
32275
34071
|
});
|
|
34072
|
+
program2.command("publish <postId>").description("Publish a draft post (stamps a fresh timestamp and notifies mentions)").option("--json", "Output as JSON").action(async (postId, opts) => {
|
|
34073
|
+
const auth = resolveAuth();
|
|
34074
|
+
const spinner = opts.json ? null : ora("Publishing draft...").start();
|
|
34075
|
+
try {
|
|
34076
|
+
const data = await amikoWebFetch(auth, `/api/posts/${encodeURIComponent(postId)}`, {
|
|
34077
|
+
method: "PATCH",
|
|
34078
|
+
body: { status: "published" }
|
|
34079
|
+
});
|
|
34080
|
+
spinner?.stop();
|
|
34081
|
+
const postUrl = data.post?.id ? `https://platform.heyamiko.com/post/${data.post.id}` : null;
|
|
34082
|
+
renderOutput(postUrl ? { ...data, post_url: postUrl } : data, (d) => {
|
|
34083
|
+
console.log(success("Post published"));
|
|
34084
|
+
const post = d.post;
|
|
34085
|
+
if (post?.id)
|
|
34086
|
+
console.log(label("Post ID", String(post.id)));
|
|
34087
|
+
if (postUrl)
|
|
34088
|
+
console.log(label("URL", postUrl));
|
|
34089
|
+
}, { json: opts.json });
|
|
34090
|
+
} catch (e5) {
|
|
34091
|
+
spinner?.stop();
|
|
34092
|
+
if (e5.status === 404) {
|
|
34093
|
+
console.error(error(`Post ${postId} not found (or not yours). Find draft ids via: amiko post drafts`));
|
|
34094
|
+
} else {
|
|
34095
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
34096
|
+
}
|
|
34097
|
+
process.exit(1);
|
|
34098
|
+
}
|
|
34099
|
+
});
|
|
32276
34100
|
program2.command("comments").description("List comments on a post").requiredOption("--id <postId>", "Target post id").option("--limit <n>", "Max results (default 20, max 100)").option("--cursor <id>", "Pagination cursor").option("--replies", "Include nested replies (default: top-level only)").option("--json", "Output as JSON").action(async (opts) => {
|
|
32277
34101
|
const auth = resolveAuth();
|
|
32278
34102
|
const spinner = opts.json ? null : ora("Loading comments...").start();
|
|
@@ -32312,7 +34136,7 @@ function registerPostCommand(program2) {
|
|
|
32312
34136
|
process.exit(1);
|
|
32313
34137
|
}
|
|
32314
34138
|
});
|
|
32315
|
-
program2.command("comment").description("Comment on a post").requiredOption("--id <postId>", "Target post id").requiredOption("--comment <text>", "Comment body").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically)
|
|
34139
|
+
program2.command("comment").description("Comment on a post").requiredOption("--id <postId>", "Target post id").requiredOption("--comment <text>", "Comment body").option("--media <pathOrUrl...>", "Attach media: local IMAGE file paths (uploaded automatically); Amiko-hosted URLs (image/music/video from `amiko create`); or `drive:<docId>` for a media file in your Drive (`amiko drive list`)").option("--twin <idOrName>", "Post as this twin (agent mode)").option("--json", "Output as JSON").action(async (opts) => {
|
|
32316
34140
|
const config2 = loadConfig();
|
|
32317
34141
|
const auth = resolveAuth();
|
|
32318
34142
|
let twinId;
|
|
@@ -32333,7 +34157,9 @@ function registerPostCommand(program2) {
|
|
|
32333
34157
|
if (opts.media?.length) {
|
|
32334
34158
|
if (spinner)
|
|
32335
34159
|
spinner.text = "Uploading media...";
|
|
32336
|
-
body.media_urls = await resolveMediaUrls(auth, opts.media
|
|
34160
|
+
body.media_urls = await resolveMediaUrls(auth, opts.media, {
|
|
34161
|
+
twin: opts.twin
|
|
34162
|
+
});
|
|
32337
34163
|
if (spinner)
|
|
32338
34164
|
spinner.text = "Posting comment...";
|
|
32339
34165
|
}
|
|
@@ -32352,6 +34178,48 @@ function registerPostCommand(program2) {
|
|
|
32352
34178
|
process.exit(1);
|
|
32353
34179
|
}
|
|
32354
34180
|
});
|
|
34181
|
+
program2.command("likers <postId>").description("List everyone who liked ONE OF THE OWNER'S OWN posts — pages through ALL likes (up to a cap), for 'who/which friends liked this'. Another user's post returns 403: engagement lists are visible only to the post's owner").option("--json", "Output as JSON").action(async (postId, opts) => {
|
|
34182
|
+
const auth = resolveAuth();
|
|
34183
|
+
const spinner = opts.json ? null : ora("Loading likes...").start();
|
|
34184
|
+
try {
|
|
34185
|
+
const { items, partial: partial3 } = await fetchAllCursor(auth, `/api/posts/${encodeURIComponent(postId)}/likes`, {
|
|
34186
|
+
itemsKey: "items",
|
|
34187
|
+
pageSize: 100
|
|
34188
|
+
});
|
|
34189
|
+
const data = { likers: items, count: items.length, partial: partial3 };
|
|
34190
|
+
spinner?.stop();
|
|
34191
|
+
renderOutput(data, (d) => {
|
|
34192
|
+
const likes = d.likers ?? [];
|
|
34193
|
+
if (likes.length === 0) {
|
|
34194
|
+
console.log(dim("No likes yet."));
|
|
34195
|
+
return;
|
|
34196
|
+
}
|
|
34197
|
+
console.log(heading(`Likes (${likes.length}${partial3 ? "+" : ""})`));
|
|
34198
|
+
const rows = [
|
|
34199
|
+
[dim("USER ID"), dim("NAME"), dim("HANDLE"), dim("WHEN")],
|
|
34200
|
+
...likes.map((l) => [
|
|
34201
|
+
l.user.id,
|
|
34202
|
+
l.user.name ?? "-",
|
|
34203
|
+
l.user.handle ?? "-",
|
|
34204
|
+
l.created_at?.slice(0, 10) ?? "-"
|
|
34205
|
+
])
|
|
34206
|
+
];
|
|
34207
|
+
console.log(table(rows));
|
|
34208
|
+
if (partial3) {
|
|
34209
|
+
console.log(dim(`
|
|
34210
|
+
⚠ Partial results: stopped at the ${likes.length}-like cap; more exist. Not the full list/count.`));
|
|
34211
|
+
}
|
|
34212
|
+
}, { json: opts.json });
|
|
34213
|
+
} catch (e5) {
|
|
34214
|
+
spinner?.stop();
|
|
34215
|
+
if (e5?.status === 403) {
|
|
34216
|
+
console.error(error("Forbidden: like lists are visible only to the post's owner. This post belongs to someone else — there is no way to list who liked it."));
|
|
34217
|
+
process.exit(1);
|
|
34218
|
+
}
|
|
34219
|
+
console.error(error(e5 instanceof Error ? e5.message : String(e5)));
|
|
34220
|
+
process.exit(1);
|
|
34221
|
+
}
|
|
34222
|
+
});
|
|
32355
34223
|
}
|
|
32356
34224
|
function registerReviewCommand(program2) {
|
|
32357
34225
|
program2.command("list").description("List twin-drafted comments awaiting your approval").option("--json", "Output as JSON").action(async (opts) => {
|
|
@@ -32975,9 +34843,9 @@ var twin = program2.command("twin").description("Update twin identity (name, des
|
|
|
32975
34843
|
var drive = program2.command("drive").alias("docs").description("Manage twin drive — upload/download/search files, organise into folders");
|
|
32976
34844
|
var voice = program2.command("voice").description("Manage twin voice — design, create, clone, reset");
|
|
32977
34845
|
var avatar = program2.command("avatar").description("Manage twin avatar image");
|
|
32978
|
-
var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, reports, matches");
|
|
32979
|
-
var users = program2.command("users").description("Search users
|
|
32980
|
-
var post = program2.command("post").description("Create posts and comments on the feed");
|
|
34846
|
+
var friends = program2.command("friends").description("Manage friendships — list, requests, add, accept, remove, nicknames, reports, matches");
|
|
34847
|
+
var users = program2.command("users").description("Search users, view public profiles, and manage follows (follow, unfollow, followers, following)");
|
|
34848
|
+
var post = program2.command("post").description("Create posts (notes) and comments on the feed — text, images, and documents");
|
|
32981
34849
|
var review = program2.command("review").description("Review queue — list, approve, or reject twin-drafted comments");
|
|
32982
34850
|
var notifications = program2.command("notifications").description("Platform notifications — list, mark as read");
|
|
32983
34851
|
registerCreditsCommand(program2);
|