@mulmobridge/line 1.1.1 → 1.2.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 +1 -0
- package/dist/index.js +25 -26
- package/dist/loading.d.ts +12 -0
- package/dist/loading.js +30 -0
- package/package.json +9 -9
package/README.md
CHANGED
|
@@ -91,6 +91,7 @@ Recommended: at least 32 characters of random data (the server logs a warning at
|
|
|
91
91
|
|
|
92
92
|
- 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.
|
|
93
93
|
- LINE limits messages to 5 per push call and ~5000 chars per message. Long replies are automatically chunked.
|
|
94
|
+
- **Loading animation** — while MulmoClaude works on a reply, the bot shows LINE's loading animation (`/v2/bot/chat/loading/start`), refreshed until the reply is pushed. LINE shows it only in one-on-one chats while the user has the chat open, so the bridge does not request it in groups or rooms. No extra setting or permission is needed.
|
|
94
95
|
- **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.
|
|
95
96
|
|
|
96
97
|
## Detailed Setup Guide
|
package/dist/index.js
CHANGED
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
// With none published the client waits (#3078)
|
|
15
15
|
// MULMOCLAUDE_AUTH_TOKEN — bearer token
|
|
16
16
|
import "dotenv/config";
|
|
17
|
-
import { createBridgeClient, chunkText, formatAckReply, installProcessGuards } from "@mulmobridge/client";
|
|
17
|
+
import { createBridgeClient, chunkText, formatAckReply, installProcessGuards, replyAfterProcessingIndicator } from "@mulmobridge/client";
|
|
18
18
|
import { createWebhookApp, createWebhookRateLimit, verifyHmacSignature, listenWebhook } from "@mulmobridge/webhook-runtime";
|
|
19
19
|
import { extractIncomingLineMessage, parseLineWebhookBody } from "./parse.js";
|
|
20
|
+
import { LINE_LOADING_REFRESH_MS, loadingSenderFor, startLineLoading } from "./loading.js";
|
|
20
21
|
const TRANSPORT_ID = "line";
|
|
21
22
|
installProcessGuards({ name: TRANSPORT_ID });
|
|
22
23
|
function readRequiredEnv() {
|
|
@@ -112,38 +113,36 @@ app.post("/webhook", webhookRateLimit, async (req, res) => {
|
|
|
112
113
|
const incoming = extractIncomingLineMessage(event);
|
|
113
114
|
if (!incoming)
|
|
114
115
|
continue;
|
|
116
|
+
const sendLoading = loadingSenderFor(event.source, () => startLineLoading(fetch, channelAccessToken, incoming.userId));
|
|
115
117
|
try {
|
|
116
|
-
|
|
117
|
-
console.log(`[line] message user=${incoming.userId} len=${incoming.text.length}`);
|
|
118
|
-
const ack = await client.send(incoming.userId, incoming.text);
|
|
119
|
-
await pushMessage(incoming.userId, formatAckReply(ack));
|
|
120
|
-
continue;
|
|
121
|
-
}
|
|
122
|
-
// kind === "image"
|
|
123
|
-
console.log(`[line] image user=${incoming.userId} messageId=${incoming.imageMessageId}`);
|
|
124
|
-
const image = await downloadLineImage(incoming.imageMessageId);
|
|
125
|
-
if (!image) {
|
|
126
|
-
await pushMessage(incoming.userId, "Sorry, I couldn't fetch that image.");
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
// chat-service's `parseMessagePayload` rejects empty `text`
|
|
130
|
-
// (`text is required`) so attachment-only messages need a
|
|
131
|
-
// placeholder body. Mirror the Telegram convention (see
|
|
132
|
-
// `packages/bridges/telegram/src/router.ts`) — an instructive
|
|
133
|
-
// prompt rather than a caption, so the agent treats the
|
|
134
|
-
// attachment as the subject and produces a useful response.
|
|
135
|
-
// The post-save EXIF hook (#1222 PR-A) writes a sidecar if
|
|
136
|
-
// GPS data is present. (Codex review on PR #1255 / #1263.)
|
|
137
|
-
const attachments = [{ mimeType: image.mimeType, data: image.bytes.toString("base64") }];
|
|
138
|
-
const placeholderText = "Describe / analyze this file.";
|
|
139
|
-
const ack = await client.send(incoming.userId, placeholderText, attachments);
|
|
140
|
-
await pushMessage(incoming.userId, formatAckReply(ack));
|
|
118
|
+
await replyAfterProcessingIndicator(sendLoading, { produce: () => produceReply(incoming), post: (replyText) => pushMessage(incoming.userId, replyText) }, { refreshMs: LINE_LOADING_REFRESH_MS, label: TRANSPORT_ID, log: console });
|
|
141
119
|
}
|
|
142
120
|
catch (err) {
|
|
143
121
|
console.error(`[line] message handling failed: ${err}`);
|
|
144
122
|
}
|
|
145
123
|
}
|
|
146
124
|
});
|
|
125
|
+
async function produceReply(incoming) {
|
|
126
|
+
if (incoming.kind === "text") {
|
|
127
|
+
console.log(`[line] message user=${incoming.userId} len=${incoming.text.length}`);
|
|
128
|
+
return formatAckReply(await client.send(incoming.userId, incoming.text));
|
|
129
|
+
}
|
|
130
|
+
console.log(`[line] image user=${incoming.userId} messageId=${incoming.imageMessageId}`);
|
|
131
|
+
const image = await downloadLineImage(incoming.imageMessageId);
|
|
132
|
+
if (!image)
|
|
133
|
+
return "Sorry, I couldn't fetch that image.";
|
|
134
|
+
// chat-service's `parseMessagePayload` rejects empty `text`
|
|
135
|
+
// (`text is required`) so attachment-only messages need a
|
|
136
|
+
// placeholder body. Mirror the Telegram convention (see
|
|
137
|
+
// `packages/bridges/telegram/src/router.ts`) — an instructive
|
|
138
|
+
// prompt rather than a caption, so the agent treats the
|
|
139
|
+
// attachment as the subject and produces a useful response.
|
|
140
|
+
// The post-save EXIF hook (#1222 PR-A) writes a sidecar if
|
|
141
|
+
// GPS data is present. (Codex review on PR #1255 / #1263.)
|
|
142
|
+
const attachments = [{ mimeType: image.mimeType, data: image.bytes.toString("base64") }];
|
|
143
|
+
const placeholderText = "Describe / analyze this file.";
|
|
144
|
+
return formatAckReply(await client.send(incoming.userId, placeholderText, attachments));
|
|
145
|
+
}
|
|
147
146
|
listenWebhook(app, { envVar: "LINE_BRIDGE_PORT", fallback: 3002 }, (port) => {
|
|
148
147
|
console.log("MulmoClaude LINE bridge");
|
|
149
148
|
console.log(`Webhook listening on http://localhost:${port}/webhook`);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const LINE_LOADING_URL = "https://api.line.me/v2/bot/chat/loading/start";
|
|
2
|
+
export declare const LINE_LOADING_SECONDS = 10;
|
|
3
|
+
export declare const LINE_LOADING_REFRESH_MS = 8000;
|
|
4
|
+
export type FetchFn = (url: string, init: RequestInit) => Promise<Response>;
|
|
5
|
+
export declare function startLineLoading(fetchFn: FetchFn, channelAccessToken: string, chatId: string): Promise<void>;
|
|
6
|
+
export declare function isOneOnOneSource(source: {
|
|
7
|
+
type?: string;
|
|
8
|
+
} | undefined): boolean;
|
|
9
|
+
/** LINE refuses the loading request outside a one-on-one chat, so there the bridge sends nothing. */
|
|
10
|
+
export declare function loadingSenderFor(source: {
|
|
11
|
+
type?: string;
|
|
12
|
+
} | undefined, startLoading: () => Promise<void>): () => Promise<void>;
|
package/dist/loading.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// LINE's loading animation (POST /v2/bot/chat/loading/start). One-to-one chats
|
|
2
|
+
// only; it disappears after `loadingSeconds` or when the bot's next message arrives.
|
|
3
|
+
export const LINE_LOADING_URL = "https://api.line.me/v2/bot/chat/loading/start";
|
|
4
|
+
// Kept short: a request that crosses the reply re-shows the animation for this long.
|
|
5
|
+
export const LINE_LOADING_SECONDS = 10;
|
|
6
|
+
export const LINE_LOADING_REFRESH_MS = 8_000;
|
|
7
|
+
const LOADING_REQUEST_TIMEOUT_MS = 10_000;
|
|
8
|
+
export async function startLineLoading(fetchFn, channelAccessToken, chatId) {
|
|
9
|
+
const res = await fetchFn(LINE_LOADING_URL, {
|
|
10
|
+
method: "POST",
|
|
11
|
+
headers: {
|
|
12
|
+
"Content-Type": "application/json",
|
|
13
|
+
Authorization: `Bearer ${channelAccessToken}`,
|
|
14
|
+
},
|
|
15
|
+
body: JSON.stringify({ chatId, loadingSeconds: LINE_LOADING_SECONDS }),
|
|
16
|
+
signal: AbortSignal.timeout(LOADING_REQUEST_TIMEOUT_MS),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) {
|
|
19
|
+
const body = await res.text().catch(() => "");
|
|
20
|
+
throw new Error(`loading/start ${res.status} ${body.slice(0, 200)}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const skipLoading = async () => { };
|
|
24
|
+
export function isOneOnOneSource(source) {
|
|
25
|
+
return source?.type === "user";
|
|
26
|
+
}
|
|
27
|
+
/** LINE refuses the loading request outside a one-on-one chat, so there the bridge sends nothing. */
|
|
28
|
+
export function loadingSenderFor(source, startLoading) {
|
|
29
|
+
return isOneOnOneSource(source) ? startLoading : skipLoading;
|
|
30
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mulmobridge/line",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "LINE bridge for MulmoBridge — connect a LINE bot to MulmoClaude",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -15,24 +15,24 @@
|
|
|
15
15
|
"scripts": {
|
|
16
16
|
"build": "tsc",
|
|
17
17
|
"prepack": "yarn build",
|
|
18
|
-
"typecheck": "tsc
|
|
18
|
+
"typecheck": "tsc -p tsconfig.typecheck.json",
|
|
19
19
|
"test": "tsx --test test/test_*.ts",
|
|
20
20
|
"lint": "eslint src test"
|
|
21
21
|
},
|
|
22
22
|
"license": "MIT",
|
|
23
23
|
"author": "Receptron Team",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@mulmobridge/client": "^1.
|
|
26
|
-
"@mulmobridge/protocol": "^1.0
|
|
25
|
+
"@mulmobridge/client": "^1.5.0",
|
|
26
|
+
"@mulmobridge/protocol": "^1.1.0",
|
|
27
27
|
"@mulmobridge/webhook-runtime": "^1.2.0",
|
|
28
|
-
"dotenv": "^
|
|
29
|
-
"express": "^5.
|
|
30
|
-
"express-rate-limit": "^8.7.
|
|
28
|
+
"dotenv": "^18.0.8",
|
|
29
|
+
"express": "^5.3.0",
|
|
30
|
+
"express-rate-limit": "^8.7.1"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
33
|
"@types/express": "^5.0.0",
|
|
34
|
-
"@types/node": "^26.5
|
|
35
|
-
"tsx": "^4.23.
|
|
34
|
+
"@types/node": "^26.6.5",
|
|
35
|
+
"tsx": "^4.23.15",
|
|
36
36
|
"typescript": "^6.0.3"
|
|
37
37
|
},
|
|
38
38
|
"homepage": "https://github.com/receptron/mulmoclaude/tree/main/packages/bridges/line#readme",
|