@mulmobridge/line 0.1.1 → 1.0.1

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
@@ -62,10 +62,29 @@ Scan the QR code in the LINE Developers Console → Messaging API tab. Send a me
62
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
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
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).
82
+
65
83
  ## Notes
66
84
 
67
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.
68
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.
69
88
 
70
89
  ## Detailed Setup Guide
71
90
 
@@ -114,6 +133,13 @@ Part of the [`@mulmobridge/*`](https://www.npmjs.com/~mulmobridge) package famil
114
133
  - [`@mulmobridge/xmpp`](https://www.npmjs.com/package/@mulmobridge/xmpp) — XMPP / Jabber
115
134
  - [`@mulmobridge/zulip`](https://www.npmjs.com/package/@mulmobridge/zulip) — Zulip
116
135
 
136
+ ## Related projects
137
+
138
+ Published from the MulmoClaude monorepo by [Receptron](https://github.com/receptron).
139
+
140
+ - **[MulmoClaude](https://github.com/receptron/mulmoclaude)** — an open-source AI assistant platform that runs on your own computer. Claude Code as the engine, a personal wiki for long-term memory, schema-driven collections for your data, and chat that summons the right GUI (markdown, charts, forms, spreadsheets, wikis) for each task.
141
+ - **[MulmoTerminal](https://github.com/receptron/mulmoterminal)** — a terminal-first cockpit for running many AI coding agents in parallel. One roster showing every session's summary and PR status, tmux-backed session persistence, git-worktree isolation, one-click PRs, and mobile push with remote reply.
142
+ - **[MulmoTerminal manual](https://receptron.github.io/mulmoterminal/)** — setup, workflows, feature reference, configuration, mobile notifications, and alternative / local model providers. Available in English and Japanese.
117
143
 
118
144
  ## License
119
145
 
package/dist/index.js CHANGED
@@ -13,22 +13,53 @@
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, chunkText } 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" + "See README for setup instructions.");
25
- 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 };
26
29
  }
30
+ const { channelSecret, channelAccessToken } = readRequiredEnv();
27
31
  const client = createBridgeClient({ transportId: TRANSPORT_ID });
28
32
  client.onPush((pushEvent) => {
29
33
  pushMessage(pushEvent.chatId, pushEvent.message).catch((err) => console.error(`[line] push send failed: ${err}`));
30
34
  });
31
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
+ }
32
63
  async function pushMessage(userId, text) {
33
64
  const messages = chunkText(text, 5000).map((messageText) => ({
34
65
  type: "text",
@@ -60,50 +91,52 @@ async function pushMessage(userId, text) {
60
91
  }
61
92
  }
62
93
  }
63
- // ── Signature verification ──────────────────────────────────────
64
- function verifySignature(body, signature) {
65
- const expected = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64");
66
- if (expected.length !== signature.length)
67
- return false;
68
- return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
69
- }
70
94
  // ── Webhook server ──────────────────────────────────────────────
71
- const app = express();
72
- app.disable("x-powered-by");
73
- app.use(express.text({ type: "application/json" }));
74
- app.post("/webhook", async (req, res) => {
95
+ const app = createWebhookApp();
96
+ const webhookRateLimit = createWebhookRateLimit();
97
+ app.post("/webhook", webhookRateLimit, async (req, res) => {
75
98
  const signature = req.headers["x-line-signature"];
76
99
  const bodyStr = req.body;
77
- if (!signature || !verifySignature(bodyStr, signature)) {
100
+ if (!signature || !verifyHmacSignature(bodyStr, signature, channelSecret)) {
78
101
  res.status(401).send("Invalid signature");
79
102
  return;
80
103
  }
81
104
  res.status(200).send("OK");
82
- let body;
83
- try {
84
- body = JSON.parse(bodyStr);
85
- }
86
- catch {
105
+ const body = parseLineWebhookBody(bodyStr);
106
+ if (!body) {
87
107
  console.error("[line] malformed JSON in webhook body");
88
108
  return;
89
109
  }
90
110
  for (const event of body.events) {
91
- if (event.type !== "message" || event.message?.type !== "text")
92
- continue;
93
- const userId = event.source?.userId;
94
- const text = event.message?.text ?? "";
95
- if (!userId || !text.trim())
111
+ const incoming = extractIncomingLineMessage(event);
112
+ if (!incoming)
96
113
  continue;
97
- console.log(`[line] message user=${userId} len=${text.length}`);
98
114
  try {
99
- const ack = await client.send(userId, text);
100
- if (ack.ok) {
101
- 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;
102
120
  }
103
- else {
104
- const status = ack.status ? ` (${ack.status})` : "";
105
- 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;
106
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));
107
140
  }
108
141
  catch (err) {
109
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.1",
3
+ "version": "1.0.1",
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,41 @@
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.4",
25
- "@mulmobridge/protocol": "^0.1.4",
25
+ "@mulmobridge/client": "^1.0.1",
26
+ "@mulmobridge/protocol": "^1.0.1",
27
+ "@mulmobridge/webhook-runtime": "^1.0.2",
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"
37
+ },
38
+ "homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/line#readme",
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+https://github.com/receptron/mulmoclaude.git",
42
+ "directory": "packages/bridges/line"
43
+ },
44
+ "keywords": [
45
+ "line",
46
+ "line-messaging-api",
47
+ "mulmobridge",
48
+ "chatbot",
49
+ "bridge",
50
+ "ai-agent",
51
+ "mulmoclaude"
52
+ ],
53
+ "bugs": {
54
+ "url": "https://github.com/receptron/mulmoclaude/issues"
33
55
  }
34
56
  }