From bdfe21a5d71c46eefa26a50d382fa1137a424ad5 Mon Sep 17 00:00:00 2001 From: enkunkun <1749280+enkunkun@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:59:45 +0900 Subject: [PATCH] feat(moa): add Album (Moa) REST service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Exposes the LINE Album subsystem as `client.base.moa`. Unlike Talk / Square, Moa is a plain HTTP JSON API on top of the LEGY proxy — not Thrift — so this service uses `client.fetch` directly with an `X-Line-ChannelToken` obtained from `channel.approveChannelAndIssueChannelToken({channelId: "1375220249"})`. - packages/linejs/base/service/moa/mod.ts: MoaService with getAlbumChannelToken() — memoised channel-token issuance for 1375220249 getAlbums({cursor, orderBy, include}) — paginated album list getPhotos({chatId, albumId, cursor, pageSize, ...}) — paginated photos downloadPhoto({chatId, albumId, oid, prefix?}) — original bytes (Uint8Array) All requests override the linejs default headers with `accept: application/json` and `content-type: application/json; charset=UTF-8`, otherwise LEGY replies with `{"code":102001,"message":"一時的なエラーが発生しました。"}` at HTTP 200. buildMoaUrl is exported as a pure helper so users writing custom Moa calls can reuse the URL encoding. - packages/linejs/base/service/moa/mod.test.ts: 5 tests covering buildMoaUrl edge cases (endpoint, encoding, undefined skipping, custom host). - packages/linejs/base/service/mod.ts: re-export MoaService. - packages/linejs/base/core/mod.ts: register `client.moa = new MoaService(this)` alongside the other services. - docs/docs/moa.md + config.mts sidebar entry: usage examples and the header override note. Verified end-to-end against a live LINE account: getAlbums returns 100 albums, getPhotos returns photo metadata with obsResourceId, and downloadPhoto returns the original JPEG bytes (245 KB for a sample image). --- docs/.vitepress/config.mts | 1 + docs/docs/moa.md | 88 +++++++ packages/linejs/base/core/mod.ts | 3 + packages/linejs/base/service/moa/mod.test.ts | 48 ++++ packages/linejs/base/service/moa/mod.ts | 258 +++++++++++++++++++ packages/linejs/base/service/mod.ts | 1 + 6 files changed, 399 insertions(+) create mode 100644 docs/docs/moa.md create mode 100644 packages/linejs/base/service/moa/mod.test.ts create mode 100644 packages/linejs/base/service/moa/mod.ts diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index 3f5027a0..1b06e9fe 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -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" }, ], }, { diff --git a/docs/docs/moa.md b/docs/docs/moa.md new file mode 100644 index 00000000..cd293b76 --- /dev/null +++ b/docs/docs/moa.md @@ -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":"一時的なエラーが発生しました。"}`. diff --git a/packages/linejs/base/core/mod.ts b/packages/linejs/base/core/mod.ts index 359964c3..ff381041 100644 --- a/packages/linejs/base/core/mod.ts +++ b/packages/linejs/base/core/mod.ts @@ -20,6 +20,7 @@ import { CallService, ChannelService, LiffService, + MoaService, RelationService, SquareLiveTalkService, SquareService, @@ -129,6 +130,7 @@ export class BaseClient extends TypedEventEmitter { readonly call: CallService; readonly channel: ChannelService; readonly liff: LiffService; + readonly moa: MoaService; readonly relation: RelationService; readonly livetalk: SquareLiveTalkService; readonly square: SquareService; @@ -203,6 +205,7 @@ export class BaseClient extends TypedEventEmitter { 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); diff --git a/packages/linejs/base/service/moa/mod.test.ts b/packages/linejs/base/service/moa/mod.test.ts new file mode 100644 index 00000000..3a680bf2 --- /dev/null +++ b/packages/linejs/base/service/moa/mod.test.ts @@ -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", + ); +}); diff --git a/packages/linejs/base/service/moa/mod.ts b/packages/linejs/base/service/moa/mod.ts new file mode 100644 index 00000000..64cc018e --- /dev/null +++ b/packages/linejs/base/service/moa/mod.ts @@ -0,0 +1,258 @@ +import type { BaseClient } from "../../core/mod.ts"; +import { InternalError } from "../../core/mod.ts"; + +/** + * Channel id used by the LINE Album (Moa) subsystem. Every Moa REST request + * must include a `X-Line-ChannelToken` issued for this channel. + */ +export const MOA_CHANNEL_ID = "1375220249"; + +const LEGY_MOA_PREFIX = "/ext/album"; + +export interface MoaAlbum { + albumId: string; + chatId: string; + title?: string; + photoCount?: number; + createTime?: number; + updateTime?: number; +} + +export interface MoaAlbumsResult { + albums: MoaAlbum[]; + cursor?: string; + nextCursor?: string; + hasMore?: boolean; +} + +export interface MoaAlbumsResponse { + code?: number; + message?: string; + result?: MoaAlbumsResult; +} + +export interface AlbumPhoto { + id?: string | number; + photoId?: string | number; + oid?: string; + obsResourceId?: { oid?: string; svc?: string; sid?: string }; + shotTime?: number; + createUserMid?: string; + ownerMid?: string; + resourceType?: string; + width?: number; + height?: number; +} + +export interface AlbumPhotosResult { + photos: AlbumPhoto[]; + nextCursor?: string; +} + +export interface AlbumPhotosResponse { + code?: number; + message?: string; + result?: AlbumPhotosResult; +} + +/** + * Build a fully-qualified Moa REST URL from `pathSuffix` (relative to + * `/ext/album`) and a params object. `undefined` values are dropped so + * callers can pass optional parameters directly. + * + * @example + * ```ts + * buildMoaUrl("/moa/v2/albums", { cursor: "", orderBy: "createTimeDesc" }) + * // -> "https://legy.line-apps.com/ext/album/moa/v2/albums?cursor=&orderBy=createTimeDesc" + * ``` + */ +export function buildMoaUrl( + endpoint: string, + pathSuffix: string, + params: Record, +): string { + const base = `https://${endpoint}${LEGY_MOA_PREFIX}${pathSuffix}`; + const entries = Object.entries(params).filter(([, v]) => v !== undefined) as [ + string, + string | number, + ][]; + if (entries.length === 0) return base; + const q = entries + .map(([k, v]) => + `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}` + ) + .join("&"); + return `${base}?${q}`; +} + +/** + * Client for the LINE Album (Moa) REST API. + * + * Unlike Talk / Square, Moa speaks plain HTTP JSON on top of the LEGY proxy + * — not Thrift — so this service uses `client.fetch` directly with an + * `X-Line-ChannelToken` obtained from `channel.approveChannelAndIssueChannelToken`. + * + * @example + * ```ts + * const first = await client.moa.getAlbums({}); + * for (const album of first.result?.albums ?? []) { + * console.log(album.title, album.photoCount); + * } + * ``` + */ +export class MoaService { + client: BaseClient; + #cachedChannelToken: string | undefined; + + constructor(client: BaseClient) { + this.client = client; + } + + /** + * Issue (and memoise) the album channel token. Every subsequent Moa REST + * call uses this token via the `X-Line-ChannelToken` header. + */ + async getAlbumChannelToken(): Promise { + if (this.#cachedChannelToken) return this.#cachedChannelToken; + const resp = await this.client.channel.approveChannelAndIssueChannelToken({ + channelId: MOA_CHANNEL_ID, + }); + const token = resp?.channelAccessToken; + if (!token) { + throw new InternalError( + "MoaError", + "approveChannelAndIssueChannelToken returned no channelAccessToken", + ); + } + this.#cachedChannelToken = token; + return token; + } + + async #fetch( + pathSuffix: string, + params: Record, + extraHeaders: Record = {}, + ): Promise { + const mid = this.client.profile?.mid; + if (!mid) { + throw new InternalError( + "MoaError", + "client.profile.mid not populated — login must complete before calling Moa", + ); + } + const token = await this.getAlbumChannelToken(); + // Moa is JSON REST. The linejs default getHeader("GET") returns + // accept/content-type application/x-thrift which triggers a + // {"code":102001,"message":"一時的なエラーが発生しました。"} rejection. + const headers: Record = { + ...this.client.request.getHeader("GET"), + accept: "application/json", + "content-type": "application/json; charset=UTF-8", + "X-Line-ChannelToken": token, + "X-Line-Mid": mid, + ...extraHeaders, + }; + const url = buildMoaUrl(this.client.endpoint, pathSuffix, params); + const res = await this.client.fetch(url, { + method: "POST", + headers, + body: new Uint8Array(), + }); + if (!res.ok) { + throw new InternalError( + "MoaError", + `Moa ${pathSuffix} HTTP ${res.status}`, + { status: res.status }, + ); + } + const json = (await res.json()) as T; + if (json.code !== undefined && json.code !== 0) { + throw new InternalError( + "MoaError", + `Moa ${pathSuffix} code=${json.code} message=${json.message}`, + { code: json.code, message: json.message }, + ); + } + return json; + } + + /** + * Get the caller's album list, one page at a time. Pass `cursor` from the + * previous response to paginate. + */ + getAlbums( + options: { cursor?: string; orderBy?: string; include?: string } = {}, + ): Promise { + return this.#fetch("/moa/v2/albums", { + cursor: options.cursor ?? "", + orderBy: options.orderBy ?? "createTimeDesc", + include: options.include ?? "", + }); + } + + /** + * Get photos inside a specific album (identified by `albumId` in the given + * `chatId`). Paginate via `cursor`. + */ + getPhotos(options: { + chatId: string; + albumId: string | number; + cursor?: string; + pageSize?: number; + orderBy?: string; + include?: string; + filterType?: string; + targetUser?: string; + }): Promise { + return this.#fetch( + `/api/v6/albums/${options.albumId}/photos`, + { + cursor: options.cursor ?? "", + pageSize: options.pageSize ?? 100, + orderBy: options.orderBy ?? "createTimeDesc", + include: options.include ?? "all", + filterType: options.filterType ?? "", + targetUser: options.targetUser, + }, + { "X-Line-Chat-Id": options.chatId }, + ); + } + + /** + * Download the original bytes of an album photo. Returns the response bytes + * so the caller can save them to disk or process them further. + * + * `prefix` defaults to `"album/a"` (still image). For videos, pass + * `"album/v"` (that is what `obsResourceId.sid === "v"` maps to). + */ + async downloadPhoto(options: { + chatId: string; + albumId: string | number; + oid: string; + prefix?: string; + }): Promise { + const token = await this.getAlbumChannelToken(); + const headers: Record = { + ...this.client.request.getHeader("GET"), + "X-Line-ChannelToken": token, + "X-Line-Album": String(options.albumId), + "X-Line-Mid": options.chatId, + }; + const prefix = options.prefix ?? "album/a"; + const url = + `https://${this.client.endpoint}/oa/r/${prefix}/${options.oid}`; + const res = await this.client.fetch(url, { + method: "POST", + headers, + body: new Uint8Array(), + }); + if (!res.ok) { + throw new InternalError( + "MoaError", + `OBS ${options.oid} HTTP ${res.status}`, + { status: res.status }, + ); + } + return new Uint8Array(await res.arrayBuffer()); + } +} diff --git a/packages/linejs/base/service/mod.ts b/packages/linejs/base/service/mod.ts index aae34108..02e5134c 100644 --- a/packages/linejs/base/service/mod.ts +++ b/packages/linejs/base/service/mod.ts @@ -11,6 +11,7 @@ export { DeviceAttestationService } from "./deviceattestation/mod.ts"; export { E2EEKeyBackupService } from "./e2eekeybackup/mod.ts"; export { HomeSafetyCheckService } from "./homesafetycheck/mod.ts"; export { LiffService } from "./liff/mod.ts"; +export { MoaService } from "./moa/mod.ts"; export { MultiProfileService } from "./multiprofile/mod.ts"; export { OaChatService } from "./oachat/mod.ts"; export { OaMembershipService } from "./oamembership/mod.ts";