@mulmobridge/line 0.1.0 → 1.0.0

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 CHANGED
@@ -14,7 +14,11 @@ LINE bridge for [MulmoClaude](https://github.com/receptron/mulmoclaude). Uses we
14
14
 
15
15
  ### 2. Set up ngrok (for development)
16
16
 
17
+ [ngrok](https://ngrok.com) is a tunneling tool that exposes a local port to the internet so LINE's webhooks can reach your machine.
18
+
17
19
  ```bash
20
+ brew install ngrok
21
+ ngrok config add-authtoken <your-token> # from ngrok dashboard
18
22
  ngrok http 3002
19
23
  # Copy the https://xxxx.ngrok-free.app URL
20
24
  ```
@@ -22,9 +26,9 @@ ngrok http 3002
22
26
  ### 3. Configure the webhook
23
27
 
24
28
  In the LINE Developers Console → Messaging API tab:
25
- - **Webhook URL**: `https://xxxx.ngrok-free.app/webhook`
29
+ - **Webhook URL**: `https://xxxx.ngrok-free.app/webhook` — the trailing `/webhook` is **required** (without it you get 404)
26
30
  - **Use webhook**: enabled
27
- - **Auto-reply messages**: disabled (so LINE's default replies don't interfere)
31
+ - **Auto-reply messages**: disabled (LINE Official Account settings → Auto-reply messages → OFF, otherwise you get double replies)
28
32
 
29
33
  ### 4. Run the bridge
30
34
 
@@ -55,11 +59,80 @@ Scan the QR code in the LINE Developers Console → Messaging API tab. Send a me
55
59
  | `LINE_BRIDGE_PORT` | No | Webhook port (default: 3002) |
56
60
  | `MULMOCLAUDE_API_URL` | No | Default `http://localhost:3001` |
57
61
  | `MULMOCLAUDE_AUTH_TOKEN` | No | Bearer token |
62
+ | `LINE_BRIDGE_DEFAULT_ROLE` | No | Role id to seed new bridge sessions with (e.g. `coder`, `general`). Applied ONLY when a line session first appears — once the user switches role via `/role <id>` the session's own role wins. Unknown role ids silently fall back to the server's default with a warn log. |
63
+ | `BRIDGE_DEFAULT_ROLE` | No | Same as above but shared across every bridge. Transport-specific `LINE_BRIDGE_DEFAULT_ROLE` wins when both are set. |
64
+
65
+ ### Auth token persistence across server restarts
66
+
67
+ The MulmoClaude server regenerates a fresh bearer token on every startup and writes it to `~/mulmoclaude/.session-token`. The bridge reads that file once at launch and keeps the token in memory — so if the server restarts while the bridge is running, the bridge keeps using the **old** token and every API call returns **401**, silently.
68
+
69
+ **Fix**: set `MULMOCLAUDE_AUTH_TOKEN` to the same long random value on **both** the server and the bridge. The server uses it verbatim instead of regenerating, so the token survives restarts and the bridge stays authenticated.
70
+
71
+ ```bash
72
+ # Server (one-time setup — same value across restarts)
73
+ MULMOCLAUDE_AUTH_TOKEN=long-random-string yarn dev
74
+
75
+ # Bridge (separate process / machine — same value)
76
+ MULMOCLAUDE_AUTH_TOKEN=long-random-string \
77
+ <bridge-specific-envs> \
78
+ npx <this-package>@latest
79
+ ```
80
+
81
+ Recommended: at least 32 characters of random data (the server logs a warning at startup for shorter values).
58
82
 
59
83
  ## Notes
60
84
 
61
85
  - LINE reply tokens expire in **1 minute**. Since Claude responses can take longer, the bridge uses **push messages** instead of reply messages. This requires your bot to be a verified/certified account for push to work with all users, OR the user must have added the bot as a friend first.
62
86
  - LINE limits messages to 5 per push call and ~5000 chars per message. Long replies are automatically chunked.
87
+ - **Inbound media** — text and image messages are forwarded to the agent. Image bytes are downloaded via the LINE Data API (`/v2/bot/message/<id>/content`) using the same channel access token; the actual format (JPEG / PNG / HEIC depending on sender) is preserved via the response Content-Type. The photo-EXIF auto-capture flow ([#1222](https://github.com/receptron/mulmoclaude/issues/1222)) writes a sidecar at `data/locations/...` when GPS data is present. Video / audio / file / sticker messages are not forwarded yet.
88
+
89
+ ## Detailed Setup Guide
90
+
91
+ For step-by-step instructions with troubleshooting:
92
+
93
+ - [English](https://github.com/receptron/mulmoclaude/blob/main/docs/message_apps/line/README.md)
94
+ - [Japanese](https://github.com/receptron/mulmoclaude/blob/main/docs/message_apps/line/README.ja.md)
95
+
96
+ ## Ecosystem
97
+
98
+ Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package family.
99
+
100
+ **Shared libraries:**
101
+
102
+ - [`@mulmobridge/client`](https://www.npmjs.com/package/@mulmobridge/client) — socket.io client library used by every bridge below
103
+ - [`@mulmobridge/protocol`](https://www.npmjs.com/package/@mulmobridge/protocol) — wire types and constants
104
+ - [`@mulmobridge/chat-service`](https://www.npmjs.com/package/@mulmobridge/chat-service) — server-side relay + session store
105
+ - [`@mulmobridge/relay`](https://www.npmjs.com/package/@mulmobridge/relay) — Cloudflare Workers webhook proxy
106
+ - [`@mulmobridge/mock-server`](https://www.npmjs.com/package/@mulmobridge/mock-server) — mock server for local bridge development
107
+
108
+ **Bridges** (one npm package per platform):
109
+
110
+ - [`@mulmobridge/bluesky`](https://www.npmjs.com/package/@mulmobridge/bluesky) — Bluesky DMs over atproto
111
+ - [`@mulmobridge/chatwork`](https://www.npmjs.com/package/@mulmobridge/chatwork) — Chatwork (Japanese business chat)
112
+ - [`@mulmobridge/cli`](https://www.npmjs.com/package/@mulmobridge/cli) — interactive terminal bridge
113
+ - [`@mulmobridge/discord`](https://www.npmjs.com/package/@mulmobridge/discord) — Discord bot via Gateway
114
+ - [`@mulmobridge/email`](https://www.npmjs.com/package/@mulmobridge/email) — IMAP poll + SMTP reply, threading preserved
115
+ - [`@mulmobridge/google-chat`](https://www.npmjs.com/package/@mulmobridge/google-chat) — Google Chat via MulmoBridge relay
116
+ - [`@mulmobridge/irc`](https://www.npmjs.com/package/@mulmobridge/irc) — IRC (Libera, Freenode, custom)
117
+ - [`@mulmobridge/line`](https://www.npmjs.com/package/@mulmobridge/line) — LINE Messaging API via MulmoBridge relay ← **this package**
118
+ - [`@mulmobridge/line-works`](https://www.npmjs.com/package/@mulmobridge/line-works) — LINE Works (enterprise LINE)
119
+ - [`@mulmobridge/mastodon`](https://www.npmjs.com/package/@mulmobridge/mastodon) — Mastodon DMs + mentions
120
+ - [`@mulmobridge/matrix`](https://www.npmjs.com/package/@mulmobridge/matrix) — Matrix / Element
121
+ - [`@mulmobridge/mattermost`](https://www.npmjs.com/package/@mulmobridge/mattermost) — Mattermost
122
+ - [`@mulmobridge/messenger`](https://www.npmjs.com/package/@mulmobridge/messenger) — Facebook Messenger via MulmoBridge relay
123
+ - [`@mulmobridge/nostr`](https://www.npmjs.com/package/@mulmobridge/nostr) — Nostr NIP-04 encrypted DMs
124
+ - [`@mulmobridge/rocketchat`](https://www.npmjs.com/package/@mulmobridge/rocketchat) — Rocket.Chat
125
+ - [`@mulmobridge/signal`](https://www.npmjs.com/package/@mulmobridge/signal) — Signal via signal-cli-rest-api
126
+ - [`@mulmobridge/slack`](https://www.npmjs.com/package/@mulmobridge/slack) — Slack Socket Mode
127
+ - [`@mulmobridge/teams`](https://www.npmjs.com/package/@mulmobridge/teams) — Microsoft Teams via Bot Framework
128
+ - [`@mulmobridge/telegram`](https://www.npmjs.com/package/@mulmobridge/telegram) — Telegram bot
129
+ - [`@mulmobridge/twilio-sms`](https://www.npmjs.com/package/@mulmobridge/twilio-sms) — SMS via Twilio Programmable Messaging
130
+ - [`@mulmobridge/viber`](https://www.npmjs.com/package/@mulmobridge/viber) — Viber Public Account bots
131
+ - [`@mulmobridge/webhook`](https://www.npmjs.com/package/@mulmobridge/webhook) — generic HTTP webhook bridge
132
+ - [`@mulmobridge/whatsapp`](https://www.npmjs.com/package/@mulmobridge/whatsapp) — WhatsApp Cloud API via MulmoBridge relay
133
+ - [`@mulmobridge/xmpp`](https://www.npmjs.com/package/@mulmobridge/xmpp) — XMPP / Jabber
134
+ - [`@mulmobridge/zulip`](https://www.npmjs.com/package/@mulmobridge/zulip) — Zulip
135
+
63
136
 
64
137
  ## License
65
138
 
package/dist/index.js CHANGED
@@ -13,38 +13,72 @@
13
13
  // MULMOCLAUDE_API_URL — default http://localhost:3001
14
14
  // MULMOCLAUDE_AUTH_TOKEN — bearer token
15
15
  import "dotenv/config";
16
- import crypto from "crypto";
17
- import express from "express";
18
- import { createBridgeClient } from "@mulmobridge/client";
16
+ import { createBridgeClient, chunkText, formatAckReply } from "@mulmobridge/client";
17
+ import { createWebhookApp, createWebhookRateLimit, verifyHmacSignature } from "@mulmobridge/webhook-runtime";
18
+ import { extractIncomingLineMessage, parseLineWebhookBody } from "./parse.js";
19
19
  const TRANSPORT_ID = "line";
20
20
  const PORT = Number(process.env.LINE_BRIDGE_PORT) || 3002;
21
- const channelSecret = process.env.LINE_CHANNEL_SECRET;
22
- const channelAccessToken = process.env.LINE_CHANNEL_ACCESS_TOKEN;
23
- if (!channelSecret || !channelAccessToken) {
24
- console.error("LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN are required.\n" +
25
- "See README for setup instructions.");
26
- process.exit(1);
21
+ function readRequiredEnv() {
22
+ const channelSecret = process.env.LINE_CHANNEL_SECRET;
23
+ const channelAccessToken = process.env.LINE_CHANNEL_ACCESS_TOKEN;
24
+ if (!channelSecret || !channelAccessToken) {
25
+ console.error("LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN are required.\nSee README for setup instructions.");
26
+ process.exit(1);
27
+ }
28
+ return { channelSecret, channelAccessToken };
27
29
  }
30
+ const { channelSecret, channelAccessToken } = readRequiredEnv();
28
31
  const client = createBridgeClient({ transportId: TRANSPORT_ID });
29
- client.onPush((ev) => {
30
- pushMessage(ev.chatId, ev.message).catch((err) => console.error(`[line] push send failed: ${err}`));
32
+ client.onPush((pushEvent) => {
33
+ pushMessage(pushEvent.chatId, pushEvent.message).catch((err) => console.error(`[line] push send failed: ${err}`));
31
34
  });
32
35
  // ── LINE API helpers ────────────────────────────────────────────
36
+ /** Download an image attached to an inbound LINE message. Returns
37
+ * the bytes + Content-Type so the caller can build an Attachment
38
+ * envelope for the chat-service socket. Returns null when the
39
+ * fetch fails (network error, 4xx, …) — caller logs and skips. */
40
+ async function downloadLineImage(messageId) {
41
+ try {
42
+ const res = await fetch(`https://api-data.line.me/v2/bot/message/${encodeURIComponent(messageId)}/content`, {
43
+ headers: { Authorization: `Bearer ${channelAccessToken}` },
44
+ signal: AbortSignal.timeout(30_000),
45
+ });
46
+ if (!res.ok) {
47
+ console.error(`[line] downloadLineImage failed: ${res.status}`);
48
+ return null;
49
+ }
50
+ // LINE returns image/jpeg by default but the Data API also
51
+ // serves the original sender format (PNG, GIF, …). Trust the
52
+ // Content-Type header so the photo-EXIF hook (#1222 PR-A) can
53
+ // tell HEIC apart from JPEG and surface the right sidecar.
54
+ const mimeType = res.headers.get("content-type")?.split(";")[0].trim() || "image/jpeg";
55
+ const arrayBuffer = await res.arrayBuffer();
56
+ return { bytes: Buffer.from(arrayBuffer), mimeType };
57
+ }
58
+ catch (err) {
59
+ console.error(`[line] downloadLineImage network error: ${err}`);
60
+ return null;
61
+ }
62
+ }
33
63
  async function pushMessage(userId, text) {
34
- const messages = chunkText(text).map((t) => ({ type: "text", text: t }));
64
+ const messages = chunkText(text, 5000).map((messageText) => ({
65
+ type: "text",
66
+ text: messageText,
67
+ }));
35
68
  // LINE allows max 5 messages per push
36
69
  for (let i = 0; i < messages.length; i += 5) {
37
70
  try {
71
+ const requestBody = {
72
+ to: userId,
73
+ messages: messages.slice(i, i + 5),
74
+ };
38
75
  const res = await fetch("https://api.line.me/v2/bot/message/push", {
39
76
  method: "POST",
40
77
  headers: {
41
78
  "Content-Type": "application/json",
42
79
  Authorization: `Bearer ${channelAccessToken}`,
43
80
  },
44
- body: JSON.stringify({
45
- to: userId,
46
- messages: messages.slice(i, i + 5),
47
- }),
81
+ body: JSON.stringify(requestBody),
48
82
  signal: AbortSignal.timeout(30_000),
49
83
  });
50
84
  if (!res.ok) {
@@ -57,62 +91,52 @@ async function pushMessage(userId, text) {
57
91
  }
58
92
  }
59
93
  }
60
- function chunkText(text, max = 5000) {
61
- if (text.length === 0)
62
- return ["(empty reply)"];
63
- const chunks = [];
64
- for (let i = 0; i < text.length; i += max) {
65
- chunks.push(text.slice(i, i + max));
66
- }
67
- return chunks;
68
- }
69
- // ── Signature verification ──────────────────────────────────────
70
- function verifySignature(body, signature) {
71
- const expected = crypto
72
- .createHmac("SHA256", channelSecret)
73
- .update(body)
74
- .digest("base64");
75
- if (expected.length !== signature.length)
76
- return false;
77
- return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
78
- }
79
94
  // ── Webhook server ──────────────────────────────────────────────
80
- const app = express();
81
- app.disable("x-powered-by");
82
- app.use(express.text({ type: "application/json" }));
83
- app.post("/webhook", async (req, res) => {
95
+ const app = createWebhookApp();
96
+ const webhookRateLimit = createWebhookRateLimit();
97
+ app.post("/webhook", webhookRateLimit, async (req, res) => {
84
98
  const signature = req.headers["x-line-signature"];
85
99
  const bodyStr = req.body;
86
- if (!signature || !verifySignature(bodyStr, signature)) {
100
+ if (!signature || !verifyHmacSignature(bodyStr, signature, channelSecret)) {
87
101
  res.status(401).send("Invalid signature");
88
102
  return;
89
103
  }
90
104
  res.status(200).send("OK");
91
- let body;
92
- try {
93
- body = JSON.parse(bodyStr);
94
- }
95
- catch {
105
+ const body = parseLineWebhookBody(bodyStr);
106
+ if (!body) {
96
107
  console.error("[line] malformed JSON in webhook body");
97
108
  return;
98
109
  }
99
110
  for (const event of body.events) {
100
- if (event.type !== "message" || event.message?.type !== "text")
101
- continue;
102
- const userId = event.source?.userId;
103
- const text = event.message?.text ?? "";
104
- if (!userId || !text.trim())
111
+ const incoming = extractIncomingLineMessage(event);
112
+ if (!incoming)
105
113
  continue;
106
- console.log(`[line] message user=${userId} len=${text.length}`);
107
114
  try {
108
- const ack = await client.send(userId, text);
109
- if (ack.ok) {
110
- await pushMessage(userId, ack.reply ?? "");
115
+ if (incoming.kind === "text") {
116
+ console.log(`[line] message user=${incoming.userId} len=${incoming.text.length}`);
117
+ const ack = await client.send(incoming.userId, incoming.text);
118
+ await pushMessage(incoming.userId, formatAckReply(ack));
119
+ continue;
111
120
  }
112
- else {
113
- const status = ack.status ? ` (${ack.status})` : "";
114
- await pushMessage(userId, `Error${status}: ${ack.error ?? "unknown"}`);
121
+ // kind === "image"
122
+ console.log(`[line] image user=${incoming.userId} messageId=${incoming.imageMessageId}`);
123
+ const image = await downloadLineImage(incoming.imageMessageId);
124
+ if (!image) {
125
+ await pushMessage(incoming.userId, "Sorry, I couldn't fetch that image.");
126
+ continue;
115
127
  }
128
+ // chat-service's `parseMessagePayload` rejects empty `text`
129
+ // (`text is required`) so attachment-only messages need a
130
+ // placeholder body. Mirror the Telegram convention (see
131
+ // `packages/bridges/telegram/src/router.ts`) — an instructive
132
+ // prompt rather than a caption, so the agent treats the
133
+ // attachment as the subject and produces a useful response.
134
+ // The post-save EXIF hook (#1222 PR-A) writes a sidecar if
135
+ // GPS data is present. (Codex review on PR #1255 / #1263.)
136
+ const attachments = [{ mimeType: image.mimeType, data: image.bytes.toString("base64") }];
137
+ const placeholderText = "Describe / analyze this file.";
138
+ const ack = await client.send(incoming.userId, placeholderText, attachments);
139
+ await pushMessage(incoming.userId, formatAckReply(ack));
116
140
  }
117
141
  catch (err) {
118
142
  console.error(`[line] message handling failed: ${err}`);
@@ -0,0 +1,45 @@
1
+ export interface LineMessage {
2
+ type: string;
3
+ text?: string;
4
+ /** LINE content id — present on media types (image / video / audio
5
+ * / file). The actual bytes live behind a separate Data API call
6
+ * (`/v2/bot/message/<id>/content`); the webhook only carries the
7
+ * reference. */
8
+ id?: string;
9
+ }
10
+ export interface LineEvent {
11
+ type: string;
12
+ replyToken?: string;
13
+ source?: {
14
+ userId?: string;
15
+ type?: string;
16
+ };
17
+ message?: LineMessage;
18
+ }
19
+ export interface LineWebhookBody {
20
+ events: LineEvent[];
21
+ }
22
+ /** Discriminated union — the webhook may surface text or media.
23
+ * Callers branch on `kind` and either send text straight to chat
24
+ * or download the media bytes via the LINE Data API.
25
+ *
26
+ * PR-C of #1222: image is the only media kind we forward today.
27
+ * Video / audio / file extend this union if/when they become
28
+ * worth fanning out to the agent. */
29
+ export type IncomingLineMessage = {
30
+ kind: "text";
31
+ userId: string;
32
+ text: string;
33
+ } | {
34
+ kind: "image";
35
+ userId: string;
36
+ imageMessageId: string;
37
+ };
38
+ /**
39
+ * Reduce a LINE webhook event to the actionable shape. Returns null
40
+ * for non-actionable events (non-message types, missing fields,
41
+ * unsupported media). Pure — no side effects, no allowlist check.
42
+ */
43
+ export declare function extractIncomingLineMessage(event: LineEvent): IncomingLineMessage | null;
44
+ /** Best-effort JSON parse for the webhook body — null on malformed input. */
45
+ export declare function parseLineWebhookBody(raw: string): LineWebhookBody | null;
package/dist/parse.js ADDED
@@ -0,0 +1,39 @@
1
+ // Pure parsing helpers for the LINE bridge webhook.
2
+ /**
3
+ * Reduce a LINE webhook event to the actionable shape. Returns null
4
+ * for non-actionable events (non-message types, missing fields,
5
+ * unsupported media). Pure — no side effects, no allowlist check.
6
+ */
7
+ export function extractIncomingLineMessage(event) {
8
+ if (event.type !== "message")
9
+ return null;
10
+ const userId = event.source?.userId;
11
+ if (!userId)
12
+ return null;
13
+ const { message } = event;
14
+ if (!message)
15
+ return null;
16
+ if (message.type === "text") {
17
+ const text = message.text ?? "";
18
+ if (!text.trim())
19
+ return null;
20
+ return { kind: "text", userId, text };
21
+ }
22
+ if (message.type === "image" && typeof message.id === "string" && message.id.length > 0) {
23
+ return { kind: "image", userId, imageMessageId: message.id };
24
+ }
25
+ return null;
26
+ }
27
+ /** Best-effort JSON parse for the webhook body — null on malformed input. */
28
+ export function parseLineWebhookBody(raw) {
29
+ try {
30
+ const parsed = JSON.parse(raw);
31
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.events)) {
32
+ return null;
33
+ }
34
+ return parsed;
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mulmobridge/line",
3
- "version": "0.1.0",
3
+ "version": "1.0.0",
4
4
  "description": "LINE bridge for MulmoBridge — connect a LINE bot to MulmoClaude",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,19 +16,23 @@
16
16
  "build": "tsc",
17
17
  "prepack": "yarn build",
18
18
  "typecheck": "tsc --noEmit",
19
- "test": "echo no tests yet"
19
+ "test": "tsx --test test/test_*.ts",
20
+ "lint": "eslint src test"
20
21
  },
21
22
  "license": "MIT",
22
23
  "author": "Receptron Team",
23
24
  "dependencies": {
24
- "@mulmobridge/client": "^0.1.0",
25
- "@mulmobridge/protocol": "^0.1.0",
25
+ "@mulmobridge/client": "^1.0.0",
26
+ "@mulmobridge/protocol": "^1.0.0",
27
+ "@mulmobridge/webhook-runtime": "^1.0.1",
26
28
  "dotenv": "^17.0.0",
27
- "express": "^5.1.0"
29
+ "express": "^5.1.0",
30
+ "express-rate-limit": "^8.6.0"
28
31
  },
29
32
  "devDependencies": {
30
33
  "@types/express": "^5.0.0",
31
- "@types/node": "^25.6.0",
34
+ "@types/node": "^26.1.1",
35
+ "tsx": "^4.23.1",
32
36
  "typescript": "^6.0.3"
33
37
  }
34
38
  }