Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default defineConfig({
// { text: "Utils", link: "/docs/utils" },
{ text: "Client Methods", link: "/docs/methods" },
{ text: "Calls", link: "/docs/call" },
{ text: "Album (Moa)", link: "/docs/moa" },
],
},
{
Expand Down
88 changes: 88 additions & 0 deletions docs/docs/moa.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Album (Moa)

`MoaService` exposes the LINE Album (Moa) REST API on `client.base.moa`. Unlike
Talk / Square, Moa speaks plain HTTP JSON on top of the LEGY proxy, so this
service uses `client.fetch` directly with a channel token issued for the album
channel (`1375220249`).

## Listing albums

```ts
import { loginWithAuthToken } from "@evex/linejs";

const client = await loginWithAuthToken("YOUR_AUTH_TOKEN", {
device: "IOSIPAD",
});

let cursor = "";
while (true) {
const resp = await client.base.moa.getAlbums({ cursor });
const result = resp.result;
for (const album of result?.albums ?? []) {
console.log(`[${album.albumId}] ${album.title} (${album.photoCount} photos)`);
}
cursor = result?.nextCursor ?? result?.cursor ?? "";
if (!cursor || !(result?.hasMore ?? true)) break;
}
```

## Listing photos in an album

```ts
let cursor = "";
while (true) {
const resp = await client.base.moa.getPhotos({
chatId: "cxxxxxxxxxxxxxxx",
albumId: "1234567890123456789",
cursor,
pageSize: 100,
});
for (const photo of resp.result?.photos ?? []) {
console.log(photo.oid, "shot at", photo.shotTime);
}
cursor = resp.result?.nextCursor ?? "";
if (!cursor) break;
}
```

## Downloading the original bytes of a photo

`downloadPhoto` returns a `Uint8Array` so the caller can save it or process it
further.

```ts
import { writeFileSync } from "node:fs";

const bytes = await client.base.moa.downloadPhoto({
chatId: "cxxxxxxxxxxxxxxx",
albumId: "1234567890123456789",
oid: "someOid",
});
writeFileSync("./out.jpg", bytes);
```

For videos, pass `prefix: "album/v"` (the `sid` field on `obsResourceId` tells
you whether the item is an image or a video):

```ts
const isVideo = photo.obsResourceId?.sid === "v";
const bytes = await client.base.moa.downloadPhoto({
chatId,
albumId,
oid: photo.obsResourceId!.oid!,
prefix: isVideo ? "album/v" : "album/a",
});
```

## Notes

- The channel token issued for `1375220249` is memoised inside the service and
reused for every subsequent call. If the token is rejected (server-side
expiry, revocation, ...) create a fresh `BaseClient` to reset the cache.
- Moa uses `X-Line-ChannelToken`, `X-Line-Mid` (your MID) and — for photo
fetches — `X-Line-Album` (the album id) and `X-Line-Mid` set to the *chat*
id. All of these are set for you automatically.
- The header override `accept: application/json` is important: the default
`client.base.request.getHeader("GET")` returns `application/x-thrift`, which
LEGY rejects for REST endpoints with
`{"code":102001,"message":"一時的なエラーが発生しました。"}`.
3 changes: 3 additions & 0 deletions packages/linejs/base/core/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
CallService,
ChannelService,
LiffService,
MoaService,
RelationService,
SquareLiveTalkService,
SquareService,
Expand Down Expand Up @@ -129,6 +130,7 @@ export class BaseClient extends TypedEventEmitter<ClientEvents> {
readonly call: CallService;
readonly channel: ChannelService;
readonly liff: LiffService;
readonly moa: MoaService;
readonly relation: RelationService;
readonly livetalk: SquareLiveTalkService;
readonly square: SquareService;
Expand Down Expand Up @@ -203,6 +205,7 @@ export class BaseClient extends TypedEventEmitter<ClientEvents> {
this.channel = new ChannelService(this);
this.liff = new LiffService(this);
this.livetalk = new SquareLiveTalkService(this);
this.moa = new MoaService(this);
this.relation = new RelationService(this);
this.square = new SquareService(this);
this.talk = new TalkService(this);
Expand Down
48 changes: 48 additions & 0 deletions packages/linejs/base/service/moa/mod.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { assertEquals } from "@std/assert";
import { buildMoaUrl } from "./mod.ts";

Deno.test("buildMoaUrl prepends LEGY host and /ext/album prefix", () => {
assertEquals(
buildMoaUrl("legy.line-apps.com", "/moa/v2/albums", {}),
"https://legy.line-apps.com/ext/album/moa/v2/albums",
);
});

Deno.test("buildMoaUrl encodes params and joins with ?", () => {
assertEquals(
buildMoaUrl("legy.line-apps.com", "/moa/v2/albums", {
cursor: "",
orderBy: "createTimeDesc",
include: "",
}),
"https://legy.line-apps.com/ext/album/moa/v2/albums?cursor=&orderBy=createTimeDesc&include=",
);
});

Deno.test("buildMoaUrl drops undefined values", () => {
assertEquals(
buildMoaUrl("legy.line-apps.com", "/api/v6/albums/42/photos", {
cursor: "",
pageSize: 100,
filterType: "",
targetUser: undefined,
}),
"https://legy.line-apps.com/ext/album/api/v6/albums/42/photos?cursor=&pageSize=100&filterType=",
);
});

Deno.test("buildMoaUrl percent-encodes special chars in values", () => {
assertEquals(
buildMoaUrl("legy.line-apps.com", "/moa/v2/albums", {
cursor: "abc/def=",
}),
"https://legy.line-apps.com/ext/album/moa/v2/albums?cursor=abc%2Fdef%3D",
);
});

Deno.test("buildMoaUrl honours a custom endpoint", () => {
assertEquals(
buildMoaUrl("legy-proxy.example.com", "/moa/v2/albums", {}),
"https://legy-proxy.example.com/ext/album/moa/v2/albums",
);
});
Loading
Loading