@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 +75 -2
- package/dist/index.js +81 -57
- package/dist/parse.d.ts +45 -0
- package/dist/parse.js +39 -0
- package/package.json +10 -6
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 (
|
|
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
|
|
17
|
-
import
|
|
18
|
-
import {
|
|
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
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
|
|
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((
|
|
30
|
-
pushMessage(
|
|
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((
|
|
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 =
|
|
81
|
-
|
|
82
|
-
app.
|
|
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 || !
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
101
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
await
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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}`);
|
package/dist/parse.d.ts
ADDED
|
@@ -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": "
|
|
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": "
|
|
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": "^
|
|
25
|
-
"@mulmobridge/protocol": "^
|
|
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": "^
|
|
34
|
+
"@types/node": "^26.1.1",
|
|
35
|
+
"tsx": "^4.23.1",
|
|
32
36
|
"typescript": "^6.0.3"
|
|
33
37
|
}
|
|
34
38
|
}
|