diff --git a/src/app.module.ts b/src/app.module.ts index 0126dc8..f54dc2a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -19,6 +19,7 @@ import { RelayModule } from './v1/relay/relay.module'; import { GatewayModule } from './v1/gateway/gateway.module'; import { DashboardModule } from './v1/dashboard/dashboard.module'; import { PaymentsModule } from './v1/payments/payments.module'; +import { LineModule } from './v1/line/line.module'; import { CropwatchMcpModule } from './v1/mcp/mcp.module'; @Module({ @@ -56,6 +57,7 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; GatewayModule, DashboardModule, PaymentsModule, + LineModule, CropwatchMcpModule, ], controllers: [AppController], diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index bbebb07..b8a2369 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -1112,6 +1112,15 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, }, }, + "/v1/line/link": { + "delete": {}, + }, + "/v1/line/link-start": { + "post": {}, + }, + "/v1/line/webhook": { + "post": {}, + }, "/v1/locations": { "get": { "parameters": [ diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index b1efdda..1cf6f4f 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -21,6 +21,8 @@ import { TrafficController } from '../traffic/traffic.controller'; import { TrafficService } from '../traffic/traffic.service'; import { WaterController } from '../water/water.controller'; import { WaterService } from '../water/water.service'; +import { LineController } from '../line/line.controller'; +import { LineService } from '../line/line.service'; type MockedMethods = Record; type ServiceRegistry = Record; @@ -363,6 +365,12 @@ describe('V1 Route Input Contracts', () => { soil: createMockedMethods(['findOne']), traffic: createMockedMethods(['findOne']), water: createMockedMethods(['findOne']), + line: createMockedMethods([ + 'verifyWebhookSignature', + 'handleEvents', + 'createLinkNonce', + 'unlink', + ]), }; const moduleBuilder = Test.createTestingModule({ @@ -374,6 +382,7 @@ describe('V1 Route Input Contracts', () => { SoilController, TrafficController, WaterController, + LineController, ], providers: [ { provide: AirService, useValue: serviceRegistry.air }, @@ -383,6 +392,7 @@ describe('V1 Route Input Contracts', () => { { provide: SoilService, useValue: serviceRegistry.soil }, { provide: TrafficService, useValue: serviceRegistry.traffic }, { provide: WaterService, useValue: serviceRegistry.water }, + { provide: LineService, useValue: serviceRegistry.line }, ], }); @@ -866,9 +876,40 @@ describe('V1 Route Input Contracts', () => { name: 'GET /v1/water/:dev_eui preserves start, end, and timezone query inputs', url: '/v1/water/DEV-001?start=2026-01-01T00:00:00.000Z&end=2026-01-02T00:00:00.000Z&timezone=America%2FDenver', }, + { + auth: true, + expectedCall: { + args: [MOCK_USER.sub], + method: 'createLinkNonce', + service: 'line', + }, + expectedStatus: 201, + method: 'post', + name: 'POST /v1/line/link-start mints a nonce for the current user', + url: '/v1/line/link-start', + }, + { + auth: true, + expectedCall: { + args: [MOCK_USER.sub], + method: 'unlink', + service: 'line', + }, + expectedStatus: 204, + method: 'delete', + name: 'DELETE /v1/line/link unlinks the current user', + url: '/v1/line/link', + }, ]; const rejectionCases: RejectionCase[] = [ + { + expectedMessage: 'Missing webhook body', + expectedStatus: 400, + method: 'post', + name: 'POST /v1/line/webhook rejects requests without a raw body', + url: '/v1/line/webhook', + }, { auth: true, body: { diff --git a/src/v1/line/line-api.client.spec.ts b/src/v1/line/line-api.client.spec.ts new file mode 100644 index 0000000..fec55ce --- /dev/null +++ b/src/v1/line/line-api.client.spec.ts @@ -0,0 +1,131 @@ +import { ConfigService } from '@nestjs/config'; +import { LineApiClient, LineApiError } from './line-api.client'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +function createClient(configValues?: Record) { + return new LineApiClient({ + get: jest.fn( + (key: string) => + (configValues ?? { + LINE_CHANNEL_ID: 'channel-1', + LINE_CHANNEL_SECRET: 'secret-1', + })[key], + ), + } as unknown as ConfigService); +} + +function urlOf(input: RequestInfo | URL): string { + if (typeof input === 'string') return input; + return input instanceof URL ? input.href : input.url; +} + +describe('LineApiClient', () => { + let fetchMock: jest.SpiedFunction; + + beforeEach(() => { + fetchMock = jest.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + it('issues one stateless token and reuses it across calls', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 900 }), + ) + .mockImplementation(() => Promise.resolve(jsonResponse(200, {}))); + + const client = createClient(); + await client.pushMessage('U1', [{ type: 'text', text: 'a' }]); + await client.pushMessage('U1', [{ type: 'text', text: 'b' }]); + + const tokenCalls = fetchMock.mock.calls.filter(([url]) => + urlOf(url).includes('/oauth2/v3/token'), + ); + expect(tokenCalls).toHaveLength(1); + + const pushCalls = fetchMock.mock.calls.filter(([url]) => + urlOf(url).includes('/v2/bot/message/push'), + ); + expect(pushCalls).toHaveLength(2); + const pushInit = pushCalls[0][1]; + expect(pushInit.headers).toMatchObject({ + authorization: 'Bearer tok-1', + }); + }); + + it('refreshes the token once it is within the expiry margin', async () => { + fetchMock + .mockResolvedValueOnce( + // expires_in 30s minus the 60s margin → immediately stale. + jsonResponse(200, { access_token: 'tok-old', expires_in: 30 }), + ) + .mockResolvedValueOnce(jsonResponse(200, {})) + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-new', expires_in: 900 }), + ) + .mockImplementation(() => Promise.resolve(jsonResponse(200, {}))); + + const client = createClient(); + await client.pushMessage('U1', [{ type: 'text', text: 'a' }]); + await client.pushMessage('U1', [{ type: 'text', text: 'b' }]); + + const tokenCalls = fetchMock.mock.calls.filter(([url]) => + urlOf(url).includes('/oauth2/v3/token'), + ); + expect(tokenCalls).toHaveLength(2); + }); + + it('maps LINE error bodies to LineApiError with status and detail', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 900 }), + ) + .mockResolvedValueOnce( + jsonResponse(403, { + message: 'Forbidden', + details: [{ message: 'user blocked the bot' }], + }), + ); + + const client = createClient(); + await expect( + client.pushMessage('U1', [{ type: 'text', text: 'a' }]), + ).rejects.toMatchObject({ + name: 'LineApiError', + status: 403, + detail: 'Forbidden; user blocked the bot', + }); + }); + + it('throws a configuration error when channel credentials are absent', async () => { + const client = createClient({}); + await expect( + client.pushMessage('U1', [{ type: 'text', text: 'a' }]), + ).rejects.toBeInstanceOf(LineApiError); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('issueLinkToken returns the token from the response body', async () => { + fetchMock + .mockResolvedValueOnce( + jsonResponse(200, { access_token: 'tok-1', expires_in: 900 }), + ) + .mockResolvedValueOnce(jsonResponse(200, { linkToken: 'link-abc' })); + + const client = createClient(); + await expect(client.issueLinkToken('U1')).resolves.toBe('link-abc'); + const linkCall = fetchMock.mock.calls.find(([url]) => + urlOf(url).includes('/v2/bot/user/U1/linkToken'), + ); + expect(linkCall).toBeDefined(); + }); +}); diff --git a/src/v1/line/line-api.client.ts b/src/v1/line/line-api.client.ts new file mode 100644 index 0000000..58970f7 --- /dev/null +++ b/src/v1/line/line-api.client.ts @@ -0,0 +1,173 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +const LINE_API_BASE = 'https://api.line.me'; +const TOKEN_URL = `${LINE_API_BASE}/oauth2/v3/token`; + +// Refresh the stateless token one minute before LINE expires it (~15 min). +const TOKEN_EXPIRY_MARGIN_MS = 60_000; + +export interface LineMessage { + type: string; + [key: string]: unknown; +} + +export class LineApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly detail: string | null, + ) { + super(message); + this.name = 'LineApiError'; + } +} + +/** + * Thin client for the LINE Messaging API. Authenticates with stateless + * channel access tokens issued from the channel ID + secret — no token is + * ever persisted (stateless tokens cannot be revoked and live 15 minutes). + */ +@Injectable() +export class LineApiClient { + private readonly logger = new Logger(LineApiClient.name); + private tokenCache: { token: string; expiresAtMs: number } | null = null; + + constructor(private readonly configService: ConfigService) {} + + get isConfigured(): boolean { + return Boolean( + this.configService.get('LINE_CHANNEL_ID') && + this.configService.get('LINE_CHANNEL_SECRET'), + ); + } + + async issueLinkToken(lineUserId: string): Promise { + const response = await this.request<{ linkToken: string }>( + 'POST', + `/v2/bot/user/${encodeURIComponent(lineUserId)}/linkToken`, + ); + return response.linkToken; + } + + async pushMessage( + lineUserId: string, + messages: LineMessage[], + ): Promise { + await this.request('POST', '/v2/bot/message/push', { + to: lineUserId, + messages, + }); + } + + async getProfile( + lineUserId: string, + ): Promise<{ displayName?: string } | null> { + try { + return await this.request<{ displayName?: string }>( + 'GET', + `/v2/bot/profile/${encodeURIComponent(lineUserId)}`, + ); + } catch (error) { + // Profile lookup is cosmetic (display name in the confirmation DM); + // a blocked bot or privacy setting must not fail the caller. + this.logger.warn(`LINE profile lookup failed: ${String(error)}`); + return null; + } + } + + private async getStatelessToken(): Promise { + if (this.tokenCache && this.tokenCache.expiresAtMs > Date.now()) { + return this.tokenCache.token; + } + + const channelId = this.configService.get('LINE_CHANNEL_ID'); + const channelSecret = this.configService.get('LINE_CHANNEL_SECRET'); + if (!channelId || !channelSecret) { + throw new LineApiError( + 'LINE channel credentials not configured', + 0, + null, + ); + } + + const response = await fetch(TOKEN_URL, { + method: 'POST', + headers: { 'content-type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + grant_type: 'client_credentials', + client_id: channelId, + client_secret: channelSecret, + }), + }); + + if (!response.ok) { + throw new LineApiError( + `LINE token issuance failed with status ${response.status}`, + response.status, + await readResponseDetail(response), + ); + } + + const body = (await response.json()) as { + access_token: string; + expires_in: number; + }; + this.tokenCache = { + token: body.access_token, + expiresAtMs: Date.now() + body.expires_in * 1000 - TOKEN_EXPIRY_MARGIN_MS, + }; + return body.access_token; + } + + private async request( + method: 'GET' | 'POST', + path: string, + body?: unknown, + ): Promise { + const token = await this.getStatelessToken(); + const response = await fetch(`${LINE_API_BASE}${path}`, { + method, + headers: { + authorization: `Bearer ${token}`, + ...(body !== undefined ? { 'content-type': 'application/json' } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + + if (!response.ok) { + throw new LineApiError( + `LINE API ${method} ${path} failed with status ${response.status}`, + response.status, + await readResponseDetail(response), + ); + } + + const text = await response.text(); + return (text ? JSON.parse(text) : {}) as T; + } +} + +// LINE error bodies are {message, details?: [{message, property}]}. +async function readResponseDetail(response: Response): Promise { + try { + const text = await response.text(); + if (!text) return null; + try { + const parsed = JSON.parse(text) as { + message?: unknown; + details?: Array<{ message?: unknown }>; + }; + const parts: string[] = []; + if (typeof parsed.message === 'string') parts.push(parsed.message); + for (const detail of parsed.details ?? []) { + if (typeof detail?.message === 'string') parts.push(detail.message); + } + return parts.length > 0 ? parts.join('; ') : text; + } catch { + return text; + } + } catch { + return null; + } +} diff --git a/src/v1/line/line.controller.ts b/src/v1/line/line.controller.ts new file mode 100644 index 0000000..b4a924b --- /dev/null +++ b/src/v1/line/line.controller.ts @@ -0,0 +1,77 @@ +import { + BadRequestException, + Controller, + Delete, + Headers, + HttpCode, + HttpStatus, + Post, + Req, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { RawBodyRequest } from '@nestjs/common'; +import type { Request } from 'express'; +import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; +import { LineService, type LineWebhookEvent } from './line.service'; + +@ApiTags('line') +@Controller({ path: 'line', version: '1' }) +export class LineController { + constructor(private readonly lineService: LineService) {} + + @Post('webhook') + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: 'Receive LINE Messaging API webhook events (signature-verified)', + }) + async handleWebhook( + @Req() req: RawBodyRequest, + @Headers('x-line-signature') signature: string | undefined, + ): Promise<{ received: boolean }> { + const rawBody: Buffer | undefined = req.rawBody; + if (!rawBody) { + throw new BadRequestException('Missing webhook body'); + } + + this.lineService.verifyWebhookSignature(rawBody, signature); + + let events: LineWebhookEvent[] = []; + try { + const parsed = JSON.parse(rawBody.toString('utf8')) as { + events?: LineWebhookEvent[]; + }; + events = Array.isArray(parsed.events) ? parsed.events : []; + } catch { + throw new BadRequestException('Malformed webhook body'); + } + + // Light events (a few HTTP calls at most); processed inline before the + // response — LINE tolerates seconds and redelivers on failure. + await this.lineService.handleEvents(events); + return { received: true }; + } + + @Post('link-start') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Mint a nonce for the LINE account-link dialog redirect', + }) + linkStart( + @CurrentUser() user: AuthenticatedUser, + ): Promise<{ nonce: string }> { + return this.lineService.createLinkNonce(user.sub); + } + + @Delete('link') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Unlink the LINE account from the current user' }) + async unlink(@CurrentUser() user: AuthenticatedUser): Promise { + await this.lineService.unlink(user.sub); + } +} diff --git a/src/v1/line/line.module.ts b/src/v1/line/line.module.ts new file mode 100644 index 0000000..85ccf13 --- /dev/null +++ b/src/v1/line/line.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { SupabaseModule } from '../../supabase/supabase.module'; +import { LineController } from './line.controller'; +import { LineService } from './line.service'; +import { LineApiClient } from './line-api.client'; + +@Module({ + imports: [SupabaseModule, ConfigModule], + controllers: [LineController], + providers: [LineService, LineApiClient], + exports: [LineService], +}) +export class LineModule {} diff --git a/src/v1/line/line.service.spec.ts b/src/v1/line/line.service.spec.ts new file mode 100644 index 0000000..3bd26df --- /dev/null +++ b/src/v1/line/line.service.spec.ts @@ -0,0 +1,376 @@ +import { ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac } from 'crypto'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { LineApiClient } from './line-api.client'; +import { LineService } from './line.service'; + +const CHANNEL_SECRET = 'test-channel-secret'; +const LINE_USER = 'U1234567890abcdef'; + +type StubResult = { + data: unknown; + error: { message: string; code?: string } | null; +}; + +// Chainable, thenable query stub: filter methods record args and return the +// chain; awaiting resolves the configured result. maybeSingle resolves it too. +function chain(result: StubResult) { + const calls: Array<{ method: string; args: unknown[] }> = []; + const stub: Record = { calls }; + for (const method of [ + 'select', + 'insert', + 'update', + 'delete', + 'eq', + 'gt', + 'lt', + ]) { + stub[method] = jest.fn((...args: unknown[]) => { + calls.push({ method, args }); + return stub; + }); + } + stub.maybeSingle = jest.fn(() => Promise.resolve(result)); + stub.then = (resolve: (value: StubResult) => unknown) => resolve(result); + return stub as Record & { + calls: Array<{ method: string; args: unknown[] }>; + }; +} + +function buildAdminClient( + stubsByTable: Record[]>, +) { + const queues = new Map( + Object.entries(stubsByTable).map(([k, v]) => [k, [...v]]), + ); + const from = jest.fn((table: string) => { + const queue = queues.get(table); + if (!queue || queue.length === 0) { + throw new Error(`Unexpected from('${table}')`); + } + return queue.length > 1 ? queue.shift()! : queue[0]; + }); + return { from }; +} + +function createService(options?: { + configValues?: Record; + adminClient?: { from: jest.Mock }; +}) { + const apiClient = { + issueLinkToken: jest.fn((_lineUserId: string) => + Promise.resolve('link-token-1'), + ), + pushMessage: jest.fn( + (_to: string, _messages: Array>) => + Promise.resolve(), + ), + getProfile: jest.fn(() => Promise.resolve(null)), + }; + const service = new LineService( + { + get: jest.fn( + (key: string) => + (options?.configValues ?? { LINE_CHANNEL_SECRET: CHANNEL_SECRET })[ + key + ], + ), + } as unknown as ConfigService, + { + getAdminClient: jest.fn( + () => options?.adminClient ?? { from: jest.fn() }, + ), + } as unknown as SupabaseService, + apiClient as unknown as LineApiClient, + ); + return { service, apiClient }; +} + +function sign(body: Buffer, secret: string): string { + return createHmac('sha256', secret).update(body).digest('base64'); +} + +describe('LineService', () => { + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => undefined); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + jest.spyOn(console, 'log').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('verifyWebhookSignature', () => { + it('fails closed when LINE_CHANNEL_SECRET is not configured', () => { + const { service } = createService({ configValues: {} }); + const body = Buffer.from('{}'); + + expect(() => + service.verifyWebhookSignature(body, sign(body, CHANNEL_SECRET)), + ).toThrow(UnauthorizedException); + }); + + it('rejects a missing signature', () => { + const { service } = createService(); + expect(() => + service.verifyWebhookSignature(Buffer.from('{}'), undefined), + ).toThrow(ForbiddenException); + }); + + it('rejects a signature computed with the wrong secret', () => { + const { service } = createService(); + const body = Buffer.from('{"events":[]}'); + expect(() => + service.verifyWebhookSignature(body, sign(body, 'wrong-secret')), + ).toThrow(ForbiddenException); + }); + + it('accepts a valid HMAC-SHA256 signature', () => { + const { service } = createService(); + const body = Buffer.from('{"events":[]}'); + expect(() => + service.verifyWebhookSignature(body, sign(body, CHANNEL_SECRET)), + ).not.toThrow(); + }); + }); + + describe('follow events', () => { + it('sends a link button to a not-yet-linked follower', async () => { + const adminClient = buildAdminClient({ + profiles: [chain({ data: null, error: null })], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { type: 'follow', source: { userId: LINE_USER } }, + ]); + + expect(apiClient.issueLinkToken).toHaveBeenCalledWith(LINE_USER); + expect(apiClient.pushMessage).toHaveBeenCalledTimes(1); + const [to, messages] = apiClient.pushMessage.mock.calls[0]; + expect(to).toBe(LINE_USER); + expect(messages[0].type).toBe('template'); + expect(JSON.stringify(messages[0])).toContain('linkToken=link-token-1'); + }); + + it('sends an already-linked notice instead when the account is bound', async () => { + const adminClient = buildAdminClient({ + profiles: [chain({ data: { id: 'user-1' }, error: null })], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { type: 'follow', source: { userId: LINE_USER } }, + ]); + + expect(apiClient.issueLinkToken).not.toHaveBeenCalled(); + expect(apiClient.pushMessage).toHaveBeenCalledTimes(1); + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(messages[0].type).toBe('text'); + }); + }); + + describe('accountLink events', () => { + const okEvent = { + type: 'accountLink', + source: { userId: LINE_USER }, + link: { result: 'ok', nonce: 'nonce-1' }, + }; + + it('binds the profile, deletes the nonce, and confirms via DM', async () => { + const nonceLookup = chain({ + data: { nonce: 'nonce-1', user_id: 'user-1', expires_at: 'later' }, + error: null, + }); + const profileUpdate = chain({ data: null, error: null }); + const nonceDelete = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ + cw_line_link_nonces: [nonceLookup, nonceDelete], + profiles: [profileUpdate], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([okEvent]); + + expect(profileUpdate.calls).toContainEqual({ + method: 'update', + args: [{ line_id: LINE_USER }], + }); + expect(profileUpdate.calls).toContainEqual({ + method: 'eq', + args: ['id', 'user-1'], + }); + expect(nonceDelete.calls).toContainEqual({ + method: 'delete', + args: [], + }); + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(String(messages[0].text)).toContain('連携が完了'); + }); + + it('does not bind when the nonce is missing or expired', async () => { + const adminClient = buildAdminClient({ + cw_line_link_nonces: [chain({ data: null, error: null })], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([okEvent]); + + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(String(messages[0].text)).toContain('失敗'); + }); + + it('sends an explanatory DM when the LINE account is linked elsewhere (23505)', async () => { + const nonceLookup = chain({ + data: { nonce: 'nonce-1', user_id: 'user-1', expires_at: 'later' }, + error: null, + }); + const profileUpdate = chain({ + data: null, + error: { message: 'duplicate', code: '23505' }, + }); + const adminClient = buildAdminClient({ + cw_line_link_nonces: [nonceLookup], + profiles: [profileUpdate], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([okEvent]); + + const [, messages] = apiClient.pushMessage.mock.calls[0]; + expect(String(messages[0].text)).toContain('別のCropWatch'); + }); + + it('ignores accountLink events with result=failed', async () => { + const { service, apiClient } = createService(); + await service.handleEvents([ + { + type: 'accountLink', + source: { userId: LINE_USER }, + link: { result: 'failed' }, + }, + ]); + expect(apiClient.pushMessage).not.toHaveBeenCalled(); + }); + }); + + describe('unfollow and message events', () => { + it('clears the link on unfollow', async () => { + const profileUpdate = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ profiles: [profileUpdate] }); + const { service } = createService({ adminClient }); + + await service.handleEvents([ + { type: 'unfollow', source: { userId: LINE_USER } }, + ]); + + expect(profileUpdate.calls).toContainEqual({ + method: 'update', + args: [{ line_id: null }], + }); + expect(profileUpdate.calls).toContainEqual({ + method: 'eq', + args: ['line_id', LINE_USER], + }); + }); + + it('re-sends the link button when an unbound user messages the bot', async () => { + const adminClient = buildAdminClient({ + profiles: [chain({ data: null, error: null })], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { type: 'message', source: { userId: LINE_USER } }, + ]); + + expect(apiClient.issueLinkToken).toHaveBeenCalledWith(LINE_USER); + }); + + it('ignores messages from bound users', async () => { + const adminClient = buildAdminClient({ + profiles: [chain({ data: { id: 'user-1' }, error: null })], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { type: 'message', source: { userId: LINE_USER } }, + ]); + + expect(apiClient.pushMessage).not.toHaveBeenCalled(); + }); + }); + + describe('batch resilience', () => { + it('continues the batch when one event handler throws', async () => { + const failingLookup = chain({ + data: null, + error: { message: 'boom' }, + }); + const okLookup = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ + profiles: [failingLookup, okLookup], + }); + const { service, apiClient } = createService({ adminClient }); + + await service.handleEvents([ + { type: 'follow', source: { userId: 'U-bad' } }, + { type: 'follow', source: { userId: LINE_USER } }, + ]); + + // Second follow still processed despite the first one failing. + expect(apiClient.issueLinkToken).toHaveBeenCalledWith(LINE_USER); + }); + + it('ignores unknown and malformed events', async () => { + const { service, apiClient } = createService(); + await service.handleEvents([ + { type: 'sticker' }, + { type: 'follow' }, // no source.userId + {} as never, + ]); + expect(apiClient.pushMessage).not.toHaveBeenCalled(); + }); + }); + + describe('createLinkNonce / unlink', () => { + it('purges expired nonces and inserts a fresh 10-minute nonce', async () => { + const purge = chain({ data: null, error: null }); + const insert = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ + cw_line_link_nonces: [purge, insert], + }); + const { service } = createService({ adminClient }); + + const { nonce } = await service.createLinkNonce('user-1'); + + expect(purge.calls[0].method).toBe('delete'); + expect(Buffer.from(nonce, 'base64').length).toBe(24); + const insertCall = insert.calls.find((c) => c.method === 'insert'); + expect(insertCall).toBeDefined(); + const row = (insertCall!.args[0] ?? {}) as Record; + expect(row.user_id).toBe('user-1'); + expect(row.nonce).toBe(nonce); + }); + + it('unlink clears line_id for the current user', async () => { + const profileUpdate = chain({ data: null, error: null }); + const adminClient = buildAdminClient({ profiles: [profileUpdate] }); + const { service } = createService({ adminClient }); + + await service.unlink('user-1'); + + expect(profileUpdate.calls).toContainEqual({ + method: 'update', + args: [{ line_id: null }], + }); + expect(profileUpdate.calls).toContainEqual({ + method: 'eq', + args: ['id', 'user-1'], + }); + }); + }); +}); diff --git a/src/v1/line/line.service.ts b/src/v1/line/line.service.ts new file mode 100644 index 0000000..37a3185 --- /dev/null +++ b/src/v1/line/line.service.ts @@ -0,0 +1,262 @@ +import { + ForbiddenException, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { createHmac, randomBytes, timingSafeEqual } from 'crypto'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { LineApiClient, type LineMessage } from './line-api.client'; + +const APP_BASE_URL = 'https://app.cropwatch.io'; +const NONCE_TTL_MS = 10 * 60 * 1000; +const UNIQUE_VIOLATION = '23505'; + +export interface LineWebhookEvent { + type: string; + source?: { type?: string; userId?: string }; + link?: { result?: string; nonce?: string }; + [key: string]: unknown; +} + +// Bilingual DM texts (ja first, matching the alert-email convention). +const DM = { + linkButtonAlt: 'CropWatchアカウント連携 / Link your CropWatch account', + linkButtonText: + 'CropWatchアカウントと連携すると、アラートをLINEで受け取れます。\nLink your CropWatch account to receive alerts on LINE.', + linkButtonLabel: '連携する / Link', + alreadyLinked: + 'このLINEアカウントは連携済みです。\nThis LINE account is already linked.', + linked: + '連携が完了しました。アラートをLINEでお送りします。\nYour account is linked. Alerts will be sent here.', + linkFailed: + '連携に失敗しました。このトークにメッセージを送ると、新しい連携ボタンをお送りします。\nLink failed — send this chat any message to get a new link button.', + linkedElsewhere: + 'このLINEアカウントは別のCropWatchアカウントに連携されています。\nThis LINE account is already linked to a different CropWatch user.', +} as const; + +@Injectable() +export class LineService { + private readonly logger = new Logger(LineService.name); + + constructor( + private readonly configService: ConfigService, + private readonly supabaseService: SupabaseService, + private readonly lineApiClient: LineApiClient, + ) {} + + // ------------------------------------------------------------------------- + // Webhook + // ------------------------------------------------------------------------- + + verifyWebhookSignature(rawBody: Buffer, signature: string | undefined): void { + const channelSecret = this.configService.get('LINE_CHANNEL_SECRET'); + if (!channelSecret) { + // Fail closed: a missing secret must never mean "accept everything". + this.logger.error( + 'LINE_CHANNEL_SECRET is not configured — rejecting LINE webhook', + ); + throw new UnauthorizedException('LINE webhook is not configured'); + } + + const expected = createHmac('sha256', channelSecret) + .update(rawBody) + .digest(); + const provided = Buffer.from(signature ?? '', 'base64'); + + if ( + provided.length !== expected.length || + !timingSafeEqual(provided, expected) + ) { + throw new ForbiddenException('Invalid LINE webhook signature'); + } + } + + async handleEvents(events: LineWebhookEvent[]): Promise { + for (const event of events) { + try { + await this.handleEvent(event); + } catch (error) { + // One bad event must not fail the batch — LINE would redeliver all. + this.logger.error( + `Failed to handle LINE ${event?.type ?? 'unknown'} event: ${String(error)}`, + ); + } + } + } + + private async handleEvent(event: LineWebhookEvent): Promise { + const lineUserId = event.source?.userId; + switch (event.type) { + case 'follow': + if (lineUserId) await this.handleFollow(lineUserId); + return; + case 'unfollow': + if (lineUserId) await this.clearLinkByLineUserId(lineUserId); + return; + case 'accountLink': + if (lineUserId) await this.handleAccountLink(lineUserId, event); + return; + case 'message': + // Recovery path: an unbound user's message re-issues the link button + // (the link token in the original DM expires after 10 minutes). + if (lineUserId && !(await this.isLinked(lineUserId))) { + await this.sendLinkButton(lineUserId); + } + return; + default: + return; + } + } + + private async handleFollow(lineUserId: string): Promise { + if (await this.isLinked(lineUserId)) { + await this.pushText(lineUserId, DM.alreadyLinked); + return; + } + await this.sendLinkButton(lineUserId); + } + + private async handleAccountLink( + lineUserId: string, + event: LineWebhookEvent, + ): Promise { + if (event.link?.result !== 'ok' || !event.link.nonce) { + this.logger.warn(`LINE account link did not complete for ${lineUserId}`); + return; + } + + const client = this.supabaseService.getAdminClient(); + const nowIso = new Date().toISOString(); + + const { data: nonceRow, error: nonceError } = await client + .from('cw_line_link_nonces') + .select('nonce, user_id, expires_at') + .eq('nonce', event.link.nonce) + .gt('expires_at', nowIso) + .maybeSingle(); + + if (nonceError) { + throw new Error(`Failed to look up link nonce: ${nonceError.message}`); + } + if (!nonceRow) { + this.logger.warn('LINE accountLink nonce missing or expired'); + await this.pushText(lineUserId, DM.linkFailed); + return; + } + + const { error: updateError } = await client + .from('profiles') + .update({ line_id: lineUserId }) + .eq('id', nonceRow.user_id); + + if (updateError) { + if (updateError.code === UNIQUE_VIOLATION) { + await this.pushText(lineUserId, DM.linkedElsewhere); + return; + } + throw new Error(`Failed to bind LINE account: ${updateError.message}`); + } + + await client + .from('cw_line_link_nonces') + .delete() + .eq('nonce', nonceRow.nonce); + await this.pushText(lineUserId, DM.linked); + this.logger.log(`Linked LINE account for user ${nonceRow.user_id}`); + } + + private async sendLinkButton(lineUserId: string): Promise { + const linkToken = await this.lineApiClient.issueLinkToken(lineUserId); + const message: LineMessage = { + type: 'template', + altText: DM.linkButtonAlt, + template: { + type: 'buttons', + text: DM.linkButtonText, + actions: [ + { + type: 'uri', + label: DM.linkButtonLabel, + uri: `${APP_BASE_URL}/account/line-link?linkToken=${encodeURIComponent(linkToken)}`, + }, + ], + }, + }; + await this.lineApiClient.pushMessage(lineUserId, [message]); + } + + // ------------------------------------------------------------------------- + // Link lifecycle (called by authenticated endpoints) + // ------------------------------------------------------------------------- + + async createLinkNonce(userId: string): Promise<{ nonce: string }> { + const client = this.supabaseService.getAdminClient(); + const nowIso = new Date().toISOString(); + + // Opportunistic cleanup keeps the table at ~0 rows without a cron. + await client.from('cw_line_link_nonces').delete().lt('expires_at', nowIso); + + const nonce = randomBytes(24).toString('base64'); + const { error } = await client.from('cw_line_link_nonces').insert({ + nonce, + user_id: userId, + expires_at: new Date(Date.now() + NONCE_TTL_MS).toISOString(), + }); + + if (error) { + throw new Error(`Failed to store link nonce: ${error.message}`); + } + + return { nonce }; + } + + async unlink(userId: string): Promise { + // line_id is deliberately excluded from the PATCH-profile whitelist; + // this service method is the only authenticated write path for it. + const { error } = await this.supabaseService + .getAdminClient() + .from('profiles') + .update({ line_id: null }) + .eq('id', userId); + + if (error) { + throw new Error(`Failed to unlink LINE account: ${error.message}`); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private async isLinked(lineUserId: string): Promise { + const { data, error } = await this.supabaseService + .getAdminClient() + .from('profiles') + .select('id') + .eq('line_id', lineUserId) + .maybeSingle(); + + if (error) { + throw new Error(`Failed to check LINE link: ${error.message}`); + } + return Boolean(data); + } + + private async clearLinkByLineUserId(lineUserId: string): Promise { + const { error } = await this.supabaseService + .getAdminClient() + .from('profiles') + .update({ line_id: null }) + .eq('line_id', lineUserId); + + if (error) { + throw new Error(`Failed to clear LINE link: ${error.message}`); + } + } + + private async pushText(lineUserId: string, text: string): Promise { + await this.lineApiClient.pushMessage(lineUserId, [{ type: 'text', text }]); + } +} diff --git a/supabase/updates/017_line_notifications.sql b/supabase/updates/017_line_notifications.sql new file mode 100644 index 0000000..c14f9af --- /dev/null +++ b/supabase/updates/017_line_notifications.sql @@ -0,0 +1,28 @@ +-- 017_line_notifications.sql +-- LINE Messaging API account linking (official account-link flow). +-- +-- cw_line_link_nonces backs the nonce leg of LINE's account-link handshake: +-- the api mints a nonce when a logged-in user confirms linking, LINE echoes it +-- back in the accountLink webhook event, and the row is deleted on consume. +-- Rows are short-lived (10 minutes); createLinkNonce also purges expired rows +-- opportunistically, so no scheduled cleanup is needed. +-- +-- Deliberately stores ONLY random nonces — never tokens of any kind +-- (see 009_remove_discord.sql for why per-user tokens are forbidden). +-- RLS is enabled with no policies: service-role (admin client) access only. + +create table public.cw_line_link_nonces ( + nonce text primary key, + user_id uuid not null references public.profiles (id) on delete cascade, + created_at timestamptz not null default now(), + expires_at timestamptz not null +); + +alter table public.cw_line_link_nonces enable row level security; + +-- One CropWatch account per LINE account. profiles.line_id already exists; +-- this makes double-linking a 23505 the webhook handler turns into a +-- friendly "already linked to another user" DM. +create unique index profiles_line_id_key + on public.profiles (line_id) + where line_id is not null; diff --git a/supabase/updates/018_line_action_type.sql b/supabase/updates/018_line_action_type.sql new file mode 100644 index 0000000..c5d0517 --- /dev/null +++ b/supabase/updates/018_line_action_type.sql @@ -0,0 +1,20 @@ +-- 018_line_action_type.sql +-- Registers the LINE alert action type. +-- +-- APPLY LAST — only after the LavinMQ-to-Alert build containing +-- LineAlertActionHandler is deployed. The rules UI action dropdown is +-- data-driven off this table, so inserting this row is what makes the LINE +-- option appear; a LINE rule firing against an older alert service falls back +-- to the logging handler (logged, never crashes), but the option should not +-- be user-visible before the handler is live. +-- +-- The name string 'LINE' is load-bearing across repos: the alert service's +-- AlertActionRouter and the rules-form branch both match on it exactly. +-- ids are manually assigned in this table (2 = EMail, 3 = LoRaWAN). + +insert into public.cw_rule_action_types (id, name) +values (4, 'LINE') +on conflict (id) do nothing; + +-- If a sequence is ever attached to cw_rule_action_types.id, bump it past +-- the manual ids: select setval(pg_get_serial_sequence('public.cw_rule_action_types', 'id'), 4, true);