Skip to content

Commit e804673

Browse files
fix(line): don't post duplicate messages when streaming to LINE (#1292)
LINE's Messaging API has no message-edit endpoint (reply/push only). When streaming is enabled, core posts a placeholder then repeatedly calls edit_message with the growing text; on LINE each edit is delivered as a brand-new message, so the reply is posted several times, each copy longer than the last (the reported bug). In the 0.9.x unified webhook server, streaming is decided globally by the UnifiedGatewayAdapter (driven by the Telegram streaming flag) and is not platform-aware, so LINE turns stream too. Fix in two layers: - gateway adapter (crates/openab-gateway/.../line.rs): dispatch_line_reply now drops edit_message/delete_message commands. The unified adapter uses a "draft" placeholder, so the final content is delivered via send_message — dropping the cosmetic edits removes the duplicates without losing content. This is the primary fix for the unified/in-process path. - core (crates/openab-core/src/gateway.rs): the WebSocket GatewayAdapter path force-disables streaming for platforms that cannot edit messages (NON_EDITABLE_PLATFORMS, incl. "line"), so a split-gateway deployment with a real placeholder never finalizes via an edit that the adapter would drop. Adds unit tests for both layers and a note in config.toml.example. Co-authored-by: luffy-aiagent <luffy-aiagent@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent db02476 commit e804673

3 files changed

Lines changed: 129 additions & 1 deletion

File tree

config.toml.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto-
5757
# # omitted = auto-detect from list (non-empty → false, empty → true)
5858
# allowed_users = ["U5678"] # only checked when allow_all_users = false
5959
# streaming = true # enable streaming (typewriter) mode
60+
# # note: LINE has no message-edit API, so it is always
61+
# # send-once (streaming is forced off to avoid
62+
# # posting duplicate, growing messages)
6063
# streaming_placeholder = false # set false for draft-based platforms (e.g. Telegram Rich Messages)
6164

6265
# --- Telegram (first-class section; alternative to TELEGRAM_* env vars) ---

crates/openab-core/src/gateway.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,31 @@ fn platform_acks_writes(platform: &str) -> bool {
4040
EDIT_RESPONSE_PLATFORMS.contains(&platform)
4141
}
4242

43+
/// Gateway platforms whose messaging API cannot edit a message after it is sent.
44+
///
45+
/// Cosmetic (typewriter) streaming works by posting a placeholder and then
46+
/// repeatedly editing it in place with the growing text. On a platform with no
47+
/// edit endpoint, each of those "edits" is delivered as a brand-new message
48+
/// instead — so the user sees the same reply posted several times, each copy
49+
/// longer than the last. Streaming is therefore force-disabled (send-once) for
50+
/// these platforms regardless of the configured `streaming` flag.
51+
///
52+
/// LINE's Messaging API only exposes reply/push (no edit), so it lives here.
53+
/// (The in-process unified adapter additionally hard-drops stray edit_message
54+
/// commands in the LINE adapter itself — see `dispatch_line_reply`.)
55+
///
56+
/// NOTE: like `EDIT_RESPONSE_PLATFORMS`, this is platform-identity standing in
57+
/// for a *capability*. The right long-term model is a capability handshake at
58+
/// gateway-connect time ("can this adapter edit messages?"); until that exists,
59+
/// any new gateway platform that lacks a message-edit API MUST be added here.
60+
const NON_EDITABLE_PLATFORMS: &[&str] = &["line"];
61+
62+
/// Whether cosmetic streaming (placeholder + in-place edits) is possible on
63+
/// `platform`. See `NON_EDITABLE_PLATFORMS`.
64+
fn platform_supports_streaming(platform: &str) -> bool {
65+
!NON_EDITABLE_PLATFORMS.contains(&platform)
66+
}
67+
4368
/// Shared filter parameters for gateway event gating.
4469
/// Used by both `run_gateway_adapter` (WebSocket) and `process_gateway_event` (unified).
4570
struct EventFilterParams<'a> {
@@ -743,7 +768,19 @@ pub async fn run_gateway_adapter(
743768
let allowed_users: HashSet<String> = params.allowed_users.into_iter().collect();
744769
let allow_bot_messages = params.allow_bot_messages;
745770
let trusted_bot_ids: HashSet<String> = params.trusted_bot_ids.into_iter().collect();
746-
let streaming = params.streaming;
771+
// Cosmetic streaming edits a placeholder in place. On platforms without an
772+
// edit API (e.g. LINE) every edit lands as a new message — growing
773+
// duplicates — so force send-once mode there regardless of config.
774+
let streaming = if params.streaming && !platform_supports_streaming(platform) {
775+
warn!(
776+
platform,
777+
"streaming is enabled but this platform cannot edit messages; \
778+
forcing send-once mode to avoid duplicate messages"
779+
);
780+
false
781+
} else {
782+
params.streaming
783+
};
747784
let streaming_placeholder = params.streaming_placeholder;
748785
let stt_config = params.stt;
749786

@@ -1473,6 +1510,30 @@ mod tests {
14731510
use super::*;
14741511
use std::collections::HashSet;
14751512

1513+
#[test]
1514+
fn line_cannot_stream_and_is_forced_send_once() {
1515+
// LINE has no message-edit API, so cosmetic streaming is impossible.
1516+
assert!(!platform_supports_streaming("line"));
1517+
}
1518+
1519+
#[test]
1520+
fn editable_platforms_still_allow_streaming() {
1521+
for platform in [
1522+
"telegram",
1523+
"slack",
1524+
"discord",
1525+
"feishu",
1526+
"teams",
1527+
"googlechat",
1528+
"wecom",
1529+
] {
1530+
assert!(
1531+
platform_supports_streaming(platform),
1532+
"{platform} should still support streaming",
1533+
);
1534+
}
1535+
}
1536+
14761537
#[test]
14771538
fn echo_allowed_throttles_repeat_within_window() {
14781539
// Unique key so we don't collide with other tests touching the global map.

crates/openab-gateway/src/adapters/line.rs

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,22 @@ pub async fn dispatch_line_reply(
658658
return false;
659659
}
660660

661+
// LINE's Messaging API cannot edit or delete a message once it is sent.
662+
// In streaming mode the core posts a placeholder then repeatedly calls
663+
// edit_message with the growing text; on an editable platform this updates in
664+
// place, but on LINE every edit_message would be delivered as a *new* message
665+
// — the reply gets reposted several times, each copy longer than the last.
666+
// Because the unified adapter uses a "draft" placeholder (no real message), the
667+
// final content is delivered separately via send_message, so dropping these
668+
// cosmetic edit/delete commands removes the duplicates without losing content.
669+
if matches!(
670+
reply.command.as_deref(),
671+
Some("edit_message") | Some("delete_message")
672+
) {
673+
info!(command = ?reply.command.as_deref(), "line: ignoring edit/delete command (LINE cannot edit messages)");
674+
return false;
675+
}
676+
661677
// Extract token from cache (drop lock before HTTP call)
662678
let cached_token = {
663679
let mut cache = reply_cache.lock().unwrap_or_else(|e| e.into_inner());
@@ -1266,4 +1282,52 @@ mod tests {
12661282
.await;
12671283
assert!(result.is_some());
12681284
}
1285+
1286+
#[tokio::test]
1287+
async fn edit_message_command_is_ignored_not_forwarded() {
1288+
// LINE cannot edit messages. A streaming `edit_message` reply must be
1289+
// dropped, never forwarded as a new Reply/Push message — otherwise each
1290+
// edit posts the growing text as a separate message (duplicate replies).
1291+
let server = MockServer::start().await;
1292+
// If the guard fails and the command falls through, the empty reply-token
1293+
// cache forces the Push API. This expectation forbids that call.
1294+
let _push = Mock::given(method("POST"))
1295+
.and(path("/v2/bot/message/push"))
1296+
.respond_with(ResponseTemplate::new(200))
1297+
.expect(0)
1298+
.mount_as_scoped(&server)
1299+
.await;
1300+
1301+
let cache: crate::ReplyTokenCache =
1302+
Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
1303+
let reply = GatewayReply {
1304+
schema: "openab.gateway.reply.v1".into(),
1305+
reply_to: "evt1".into(),
1306+
platform: "line".into(),
1307+
channel: ReplyChannel {
1308+
id: "U123".into(),
1309+
thread_id: None,
1310+
},
1311+
content: Content {
1312+
content_type: "text".into(),
1313+
text: "partial streamed text".into(),
1314+
attachments: vec![],
1315+
},
1316+
command: Some("edit_message".into()),
1317+
request_id: None,
1318+
quote_message_id: None,
1319+
};
1320+
1321+
let used_reply = dispatch_line_reply(
1322+
&reqwest::Client::new(),
1323+
"line_token",
1324+
&cache,
1325+
&reply,
1326+
&server.uri(),
1327+
)
1328+
.await;
1329+
1330+
assert!(!used_reply, "edit_message must not use the Reply API");
1331+
// `_push` expect(0) is verified on drop: no push request was sent.
1332+
}
12691333
}

0 commit comments

Comments
 (0)