Skip to content

feat(gateway): support images and audio for LINE/Telegram#726

Closed
iamninihuang wants to merge 8 commits intoopenabdev:mainfrom
iamninihuang:feat/690-gateway-media-support
Closed

feat(gateway): support images and audio for LINE/Telegram#726
iamninihuang wants to merge 8 commits intoopenabdev:mainfrom
iamninihuang:feat/690-gateway-media-support

Conversation

@iamninihuang
Copy link
Copy Markdown
Contributor

@iamninihuang iamninihuang commented May 4, 2026

Summary

Added support for incoming images and audio/voice messages for LINE and Telegram adapters.

The Custom Gateway currently only supports text messages. This PR implements a Media Proxy that downloads media from platform APIs (which require auth) and serves it at a temporary local HTTP endpoint. This allows OpenAB Core and AI agents to fetch and analyze media via standard URLs, mirroring the image-understanding workflow already present in the Discord adapter.

Features

  • Inbound Media Proxy: Automatic download of images and audio from LINE/Telegram.
  • MediaStore: In-memory caching of media with configurable TTL (default 300s).
  • Schema Update: Added attachments field to GatewayEvent to propagate media URLs to the agent.
  • STT Support: Works with OpenAB's built-in speech-to-text pipeline.

Testing

Scenario Result
LINE: Send image → Gateway downloads & AI analyzes PASS
Telegram: Send image → Gateway downloads & AI analyzes PASS
LINE: Send audio/voice → Gateway downloads & STT works PASS
Telegram: Send audio/voice → Gateway downloads & STT works PASS
cargo test — 96 passed PASS
cargo clippy — 0 warnings PASS

Breaking Changes

None. Added optional attachments field to the gateway event schema. New environment variables GATEWAY_MEDIA_BASE_URL and GATEWAY_MEDIA_STORE_TTL have safe defaults.

Discord Discussion URL

https://discord.com/channels/1491295327620169908/1500160821567684660/1499859716409393172

Closes #690

Copilot AI review requested due to automatic review settings May 4, 2026 06:18
@iamninihuang iamninihuang requested a review from thepagent as a code owner May 4, 2026 06:18
@github-actions github-actions Bot added pending-screening PR awaiting automated screening closing-soon PR missing Discord Discussion URL — will auto-close in 3 days labels May 4, 2026
@github-actions github-actions Bot removed the closing-soon PR missing Discord Discussion URL — will auto-close in 3 days label May 4, 2026
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR aims to add inbound media support to the custom gateway so LINE and Telegram messages can carry image/audio data into OpenAB through temporary proxy URLs. It extends the gateway-side event schema and adapters, while also updating platform docs to describe the new media behavior.

Changes:

  • Add attachment fields to gateway events plus an in-memory /media/:uuid proxy store in the gateway.
  • Update Telegram and LINE webhook handlers to fetch inbound image/audio media and attach proxy URLs to emitted gateway events.
  • Update LINE/Telegram/config docs, and add a separate feature-request document related to LINE reply/push behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
gateway/src/schema.rs Extends gateway event schema with attachments.
gateway/src/main.rs Adds media store state, media route, public URL config, and cleanup task.
gateway/src/adapters/telegram.rs Downloads Telegram photo/audio media and adds attachment URLs to events.
gateway/src/adapters/line.rs Downloads LINE image/audio media and adds attachment URLs to events.
gateway/src/adapters/googlechat.rs Small dead-code annotation cleanup.
gateway/src/adapters/feishu.rs Small lint/compatibility cleanup in Feishu adapter.
docs/telegram.md Documents Telegram media support.
docs/line.md Documents LINE media support.
docs/feature-requests/line-reply-api.md Adds a separate feature-request document for LINE reply/push strategy.
docs/config-reference.md Adds gateway media-related environment variable documentation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateway/src/schema.rs
Comment on lines +17 to +18
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub attachments: Vec<Attachment>,
Comment thread gateway/src/main.rs Outdated

let listen_addr = std::env::var("GATEWAY_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into());
let ws_token = std::env::var("GATEWAY_WS_TOKEN").ok();
let public_url = std::env::var("GATEWAY_PUBLIC_URL").unwrap_or_else(|_| "http://localhost:8080".into());
Comment thread gateway/src/main.rs
Comment on lines +36 to +38
/// uuid -> (binary data, mime type, created_at)
pub type MediaStore = Arc<std::sync::Mutex<HashMap<String, (Vec<u8>, String, Instant)>>>;

Comment thread gateway/src/adapters/line.rs Outdated
Comment on lines +100 to +139
let url = format!("https://api-data.line.me/v2/bot/message/{}/content", msg.id);
let client = reqwest::Client::new();
let resp = client
.get(url)
.bearer_auth(access_token)
.send()
.await;

if let Ok(r) = resp {
let mime = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(if msg.message_type == "image" {
"image/jpeg"
} else {
"audio/x-m4a"
})
.to_string();

if let Ok(data) = r.bytes().await {
let uuid = uuid::Uuid::new_v4().to_string();
{
let mut store =
state.media_store.lock().unwrap_or_else(|e| e.into_inner());
store.insert(
uuid.clone(),
(data.to_vec(), mime, std::time::Instant::now()),
);
}
attachments.push(Attachment {
attachment_type: msg.message_type.clone(),
url: format!("{}/media/{}", state.public_url, uuid),
});
if text.is_empty() {
text = format!("[{}]", msg.message_type);
}
info!(id = %msg.id, uuid = %uuid, "proxied LINE inbound media");
}
}
Comment thread gateway/src/main.rs
Comment on lines +223 to +234
let entry = {
let cache = state.media_store.lock().unwrap_or_else(|e| e.into_inner());
cache.get(&uuid).cloned()
};

if let Some((data, mime, _)) = entry {
axum::response::Response::builder()
.header("content-type", mime)
.body(axum::body::Body::from(data))
.unwrap()
} else {
axum::http::StatusCode::NOT_FOUND.into_response()
Comment thread gateway/src/adapters/line.rs Outdated
Comment on lines +109 to +138
let mime = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(if msg.message_type == "image" {
"image/jpeg"
} else {
"audio/x-m4a"
})
.to_string();

if let Ok(data) = r.bytes().await {
let uuid = uuid::Uuid::new_v4().to_string();
{
let mut store =
state.media_store.lock().unwrap_or_else(|e| e.into_inner());
store.insert(
uuid.clone(),
(data.to_vec(), mime, std::time::Instant::now()),
);
}
attachments.push(Attachment {
attachment_type: msg.message_type.clone(),
url: format!("{}/media/{}", state.public_url, uuid),
});
if text.is_empty() {
text = format!("[{}]", msg.message_type);
}
info!(id = %msg.id, uuid = %uuid, "proxied LINE inbound media");
}
Comment thread gateway/src/adapters/telegram.rs Outdated
Comment on lines +119 to +155
if let Ok(body) = resp.json::<serde_json::Value>().await {
if let Some(file_path) = body["result"]["file_path"].as_str() {
// 2. Download the file
let download_url = format!("{TELEGRAM_API_BASE}/file/bot{token}/{file_path}");
if let Ok(r) = client.get(download_url).send().await {
let mime = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(if m_type == "image" {
"image/jpeg"
} else {
"audio/ogg"
})
.to_string();

if let Ok(data) = r.bytes().await {
let uuid = uuid::Uuid::new_v4().to_string();
{
let mut store =
state.media_store.lock().unwrap_or_else(|e| e.into_inner());
store.insert(
uuid.clone(),
(data.to_vec(), mime, std::time::Instant::now()),
);
}
attachments.push(Attachment {
attachment_type: m_type.into(),
url: format!("{}/media/{}", state.public_url, uuid),
});
if text.is_empty() {
text = format!("[{}]", m_type);
}
info!(id = %file_id, uuid = %uuid, "proxied Telegram inbound media");
}
}
}
Comment thread docs/config-reference.md Outdated
Comment on lines +85 to +87
| `GATEWAY_MEDIA_BASE_URL` | Public base URL for media proxy (e.g. `https://gateway.example.com`). | `http://127.0.0.1:9090` |
| `GATEWAY_MEDIA_STORE_TTL` | Seconds to keep media in memory before expiration. | `300` |

Comment thread docs/feature-requests/line-reply-api.md Outdated
Comment on lines +1 to +7
# Feature Request: Hybrid LINE Reply/Push API Strategy

**Title**: `feat(gateway): implement hybrid LINE Reply/Push API strategy`

**Labels**: `feature`

**GitHub Issue**: [#607](https://github.com/openabdev/openab/issues/607)
@shaun-agent
Copy link
Copy Markdown
Contributor

OpenAB PR Screening

This is auto-generated by the OpenAB project-screening flow for context collection and reviewer handoff.
Click 👍 if you find this useful. Human review will be done within 24 hours. We appreciate your support and contribution 🙏

Screening report ## Intent

PR #726 aims to let the custom gateway handle non-text inbound messages from LINE and Telegram. The current operator-visible gap is that those adapters only pass text into OpenAB, so images, audio clips, and voice messages sent by users cannot be analyzed by agents or routed through speech-to-text.

The PR proposes solving this by downloading protected platform media through the gateway, exposing it through temporary local HTTP URLs, and adding those URLs to gateway events as attachments.

Feat

This is a feature with a schema extension.

Behavioral change:

  • LINE image messages become gateway events with fetchable attachment URLs.
  • Telegram photo/image messages become gateway events with fetchable attachment URLs.
  • LINE and Telegram audio or voice messages can flow into OpenAB’s STT path.
  • Gateway events gain an optional attachments field.
  • Gateway runtime gains a temporary in-memory media proxy/cache with TTL configuration.

Who It Serves

Primary beneficiaries:

  • LINE and Telegram end users who expect agents to understand images and voice/audio.
  • Agent runtime operators who need platform media normalized into URL-based inputs.
  • Maintainers working toward feature parity across Discord, LINE, and Telegram adapters.

Secondary beneficiaries:

  • Reviewers, because a shared attachment shape reduces adapter-specific handling in downstream code.
  • Deployers, assuming the media proxy behavior is documented clearly enough to configure safely.

Rewritten Prompt

Implement inbound media support for LINE and Telegram in the gateway.

Add an optional attachments field to GatewayEvent using a schema compatible with existing agent media ingestion. For LINE and Telegram inbound image, audio, and voice messages, download the platform-protected media using the adapter’s authenticated API client, store it temporarily in a gateway-owned media store with configurable TTL, and expose it through a local HTTP media endpoint. The generated attachment URLs must be passed through gateway events so OpenAB Core and agents can fetch images and audio using standard URLs.

Include configuration for media base URL and TTL with safe defaults, document the new settings, and add tests or focused validation for:

  • Text-only events remain unchanged.
  • LINE image events produce valid attachments.
  • Telegram image events produce valid attachments.
  • LINE audio or voice events produce STT-compatible attachments.
  • Telegram audio or voice events produce STT-compatible attachments.
  • Expired media is no longer served.
  • Missing or failed media downloads produce useful logs without crashing the gateway.

Avoid unrelated adapter refactors unless required by the shared attachment schema.

Merge Pitch

This is worth advancing because it closes a real parity gap: Discord already has an image-understanding path, while LINE and Telegram currently lose media context. Normalizing protected platform media into temporary gateway URLs is a practical bridge between chat platforms and agent media analysis.

Risk profile is moderate. The main reviewer concerns should be around serving local media safely, memory-only cache behavior under load, URL exposure, TTL cleanup, and whether the schema shape matches downstream agent expectations. The feature is mergeable if the proxy is clearly scoped, configurable, and tested for expiry and failure paths.

Best-Practice Comparison

OpenClaw principles that apply:

  • Gateway-owned scheduling: not directly relevant; this is request/event-time media handling, not scheduled jobs.
  • Durable job persistence: mostly not relevant unless media processing becomes asynchronous later.
  • Isolated executions: relevant in spirit. Media downloads and serving should not let one platform request destabilize the gateway.
  • Explicit delivery routing: relevant. Attachments should be explicitly attached to the event payload rather than inferred downstream.
  • Retry/backoff and run logs: partially relevant. Media download failures should have structured logs; retries may be useful but should not block the initial merge unless platform APIs are flaky.

Hermes Agent principles that apply:

  • Gateway daemon tick model: not directly relevant.
  • File locking to prevent overlap: not relevant for in-memory cache unless later moved to disk.
  • Atomic writes for persisted state: not relevant unless the media store becomes persistent.
  • Fresh session per scheduled run: not relevant.
  • Self-contained prompts for scheduled tasks: not relevant.
  • Atomicity principle is still useful: attachment metadata should only be emitted when the gateway has successfully stored or can serve the media.

The relevant best-practice takeaway is that the gateway should own media normalization explicitly, keep temporary state bounded, log failures, and avoid leaking platform-authenticated URLs directly to agents.

Implementation Options

Option 1: Conservative adapter-local support

Implement LINE and Telegram media downloads directly inside each adapter, add optional attachments, and serve cached media through a simple in-memory gateway endpoint.

Pros: fastest path, minimal architectural churn, likely matches the PR shape.
Cons: duplication between adapters, media behavior may become inconsistent as more platforms add support.

Option 2: Balanced shared media proxy

Introduce a small shared MediaStore and media-proxy service used by LINE and Telegram adapters. Adapters are responsible only for identifying media and calling a common helper to download, cache, and emit attachment metadata.

Pros: keeps adapter code focused, easier to test expiry and serving behavior, sets a reusable pattern for future platforms.
Cons: slightly more review surface than adapter-local changes.

Option 3: Ambitious durable media pipeline

Create a gateway media subsystem with pluggable storage, durable metadata, background cleanup, configurable size limits, richer content-type handling, and future support for async processing or retries.

Pros: strongest long-term reliability and scale story.
Cons: larger design burden, slower to merge, likely too much for the current PR unless OpenAB already needs durable media retention.

Option 4: Downstream-only media handling

Pass platform media identifiers to OpenAB Core or agents and let them fetch media using platform credentials.

Pros: avoids running a media-serving endpoint in the gateway.
Cons: leaks platform-specific auth concerns downstream, weakens adapter normalization, and makes agents responsible for platform APIs.

Comparison Table

Option Speed to ship Complexity Reliability Maintainability User impact Fit for OpenAB right now
1. Adapter-local support High Low Medium Medium-low High Good if scoped tightly
2. Shared media proxy Medium Medium High High High Best
3. Durable media pipeline Low High High Medium-high High Premature unless scale issues exist
4. Downstream-only handling Medium Medium Low-medium Low Medium Poor

Recommendation

Advance the PR toward Option 2: a shared gateway-owned media proxy with a bounded in-memory store, explicit attachment schema, TTL expiry, and clear docs.

That is the right merge discussion target because it solves the user-facing media gap without turning this PR into a broader storage or job-processing redesign. The next reviewer should focus on whether the current implementation already has a clean shared MediaStore, whether expiry and failure behavior are tested, and whether LINE/Telegram adapter changes are limited to platform-specific media discovery and authenticated download.

If the PR currently mixes this with unrelated docs or adapter cleanup, split only the unrelated material. Keep the schema, media proxy, LINE support, Telegram support, and config docs together because they form one coherent feature.

@iamninihuang iamninihuang force-pushed the feat/690-gateway-media-support branch from 544163c to 99e0d63 Compare May 4, 2026 06:34
@eric-yh-huang
Copy link
Copy Markdown

@copilot Please re-review. All issues resolved.

@chaodu-agent

This comment has been minimized.

iamninihuang pushed a commit to iamninihuang/openab that referenced this pull request May 4, 2026
…bdev#726)

- Update GATEWAY_MEDIA_BASE_URL default in docs
- Add GATEWAY_MEDIA_STORE_MAX_ENTRIES limit to prevent OOM
- Update Axum route syntax to 0.8
- Add rationale comments for reply_to auto-fill
- Fix indentation in Telegram adapter
- Fix clippy warnings in cron.rs
@chaodu-agent

This comment has been minimized.

chaodu-agent
chaodu-agent previously approved these changes May 4, 2026
Copy link
Copy Markdown
Collaborator

@chaodu-agent chaodu-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve — contributor addressed all blocking issues from first review. Media proxy architecture is solid.

@chaodu-agent chaodu-agent removed the pending-screening PR awaiting automated screening label May 4, 2026
Copy link
Copy Markdown
Contributor

@wangyuyan-agent wangyuyan-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review Summary

Solid media proxy architecture that follows #690 recommendations. Testing is comprehensive and the implementation is clean.

However, I found 3 high-severity issues that must be fixed before merge:


🔴 High Severity — Must Fix

1. TTL not enforced in media_handler

Issue: Expired media is still served until the next cleanup cycle (up to 60s after expiration).

Location: gateway/src/main.rsmedia_handler

Current code:

if let Some((data, mime, _)) = entry {  // ← timestamp discarded
    axum::response::Response::builder()
        .header("content-type", mime)
        .body(axum::body::Body::from(data))
        .unwrap()
}

Fix:

if let Some((data, mime, created_at)) = entry {
    if created_at.elapsed().as_secs() >= state.media_ttl {
        return axum::http::StatusCode::NOT_FOUND.into_response();
    }
    axum::response::Response::builder()
        .header("content-type", mime)
        .body(axum::body::Body::from(data))
        .unwrap()
}

2. Memory unbounded by size

Issue: GATEWAY_MEDIA_STORE_MAX_ENTRIES=1000 limits entry count but not total bytes. 1000 × 20MB images = 20GB RAM.

Location: gateway/src/adapters/line.rs and telegram.rs — media download

Additional issue: r.bytes().await loads the full file into memory before checking if the store is full, so rejected entries still consume memory temporarily.

Recommended fix: Add a per-file size limit and check it before downloading:

// In main.rs, add to AppState:
pub media_max_file_size: u64,  // from GATEWAY_MEDIA_MAX_FILE_SIZE, default 10MB

// In line.rs and telegram.rs, before r.bytes().await:
let content_length = r.headers()
    .get("content-length")
    .and_then(|v| v.to_str().ok())
    .and_then(|s| s.parse::<u64>().ok())
    .unwrap_or(0);

if content_length > state.media_max_file_size {
    warn!(size = content_length, "media too large, skipping");
    continue;  // or break, depending on context
}

// Then proceed with r.bytes().await

This prevents OOM from a single large file and gives users a clear error message.


3. reply_to auto-fill can cross-contaminate channels

Issue: last_event_id is keyed by channel.id, but if a reply has an empty or wrong channel.id, it will pick up the last event from a different channel. This causes LINE reply tokens to be used for the wrong message, leading to silent failures.

Location: gateway/src/main.rshandle_oab_connection

Current code:

if reply.reply_to.is_empty() {
    let last = last_event_id_for_recv.lock().await;
    if let Some(eid) = last.get(&reply.channel.id) {  // ← no validation
        reply.reply_to = eid.clone();
    }
}

Fix: Only auto-fill if the channel.id is non-empty and matches a known channel:

if reply.reply_to.is_empty() && !reply.channel.id.is_empty() {
    let last = last_event_id_for_recv.lock().await;
    if let Some(eid) = last.get(&reply.channel.id) {
        reply.reply_to = eid.clone();
    }
}

🟡 Medium Severity — Should Fix

4. /media/{uuid} endpoint has no authentication

Issue: Anyone who knows a UUID can download media. In typical deployments where the gateway is behind a firewall, this is low risk. However, if the gateway's port 8080 is exposed to the internet, UUIDs could leak via logs (info!(uuid = %uuid, ...)), error messages, or monitoring tools.

Location: gateway/src/main.rs — Router setup

Risk assessment:

  • Low risk if gateway is internal-only (typical deployment)
  • Medium risk if gateway port is publicly accessible

Recommended mitigation (choose one):

Option A (deployment-level): Document that the gateway's HTTP port should not be exposed to the public internet. Only the webhook paths need to be accessible (via reverse proxy if needed).

Option B (code-level): Add a shared secret query parameter:

// In AppState:
pub media_secret: String,  // from GATEWAY_MEDIA_SECRET env var

// In media_handler:
async fn media_handler(
    State(state): State<Arc<AppState>>,
    axum::extract::Path(uuid): axum::extract::Path<String>,
    axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
) -> impl IntoResponse {
    if params.get("secret") != Some(&state.media_secret) {
        return axum::http::StatusCode::FORBIDDEN.into_response();
    }
    // ... rest of handler
}

// When constructing URLs in adapters:
url: format!("{}/media/{}?secret={}", state.public_url, uuid, state.media_secret),

Note: UUIDv4 has 122 bits of entropy, making brute-force infeasible within the 300s TTL. The main risk is UUID leakage, not guessing.


5. LINE media silently dropped when access_token is None

Issue: If LINE_ACCESS_TOKEN is not set, image/audio messages hit continue with no warning. Users get no response and no error.

Location: gateway/src/adapters/line.rs

Fix: Add a warning log:

} else if msg.message_type != "text" {
    warn!(msg_type = %msg.message_type, "LINE media message dropped (access_token not configured)");
    continue;
}

6. Internal URL leaked to LLM on download failure

Issue: When media::download_and_encode_image fails, the fallback pushes [Image attachment URL]: http://localhost:8080/media/{uuid} to the LLM. This exposes internal infrastructure details.

Location: src/gateway.rsbuild_attachment_blocks

Fix: Use a generic message instead:

blocks.push(ContentBlock::Text {
    text: "[Image attachment failed to load]".to_string(),
});

Or omit the fallback entirely (just skip the attachment).


7. Mutex poisoning recovery may proceed with corrupted state

Issue: All lock() calls use unwrap_or_else(|e| e.into_inner()), which recovers poisoned mutexes. If a thread panics while holding the lock, subsequent operations proceed on potentially inconsistent state.

Location: line.rs, telegram.rs, main.rs — all MediaStore accesses

Impact: Low probability (requires a panic inside a critical section), but could lead to serving wrong media or double-counting entries.

Recommendation: Either:

  • Accept the risk (document it), or
  • Panic on poisoned mutex: .lock().expect("media store poisoned")

The current approach is a reasonable trade-off for availability over strict consistency.


✅ What I Like

  • Clean URL-proxy architecture that decouples platform auth from core
  • Backward-compatible schema with skip_serializing_if
  • STT integration works out of the box
  • Good fallback behavior (text URL if download fails)
  • Comprehensive testing (LINE/Telegram image/audio, STT)

Verdict

Request changes — Issues #1-3 are high-severity and must be fixed before merge. Issues #4-7 are medium-severity and should be addressed but are not blocking if deployment follows best practices (gateway not exposed to public internet).

Once the high-severity issues are fixed, this PR will be a solid foundation for multi-platform media support. 🚀

Copy link
Copy Markdown
Contributor

@wangyuyan-agent wangyuyan-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up: Revised Severity Assessment (K8s Deployment Context)

After reviewing the Helm chart (charts/openab/templates/gateway.yaml), the gateway Service is ClusterIP — port 8080 is cluster-internal only. This changes the severity of a few items from my previous review.


🔄 Revised: Issue #4/media/{uuid} endpoint auth

Previous assessment: 🟡 Medium — recommended adding a shared secret query param.

Revised assessment: ✅ Non-issue in standard deployment. ClusterIP means the media endpoint is only reachable from within the cluster. External actors cannot access it. The Option B code I provided (secret query param) is unnecessary complexity for this deployment model and can be ignored.

Apologies for the noise.


🔴 New Issue (previously missed): GATEWAY_PUBLIC_URL default silently breaks media in K8s

The default http://localhost:8080 is actively broken in any Kubernetes deployment. The gateway and OAB core run in separate Pods — there is no shared localhost. Media URLs generated with the default will point to the gateway container's own loopback interface, which OAB core cannot reach.

The result: media is downloaded, stored, and the attachment URL is emitted — but OAB core silently fails to fetch it. No error, no log at the OAB side, media just disappears.

This is not a "remember to configure it" edge case — it is a guaranteed silent failure for every K8s deployment that doesn't set this variable.

Recommended fix: either make the variable required (startup error if unset when any media-capable adapter is configured), or at minimum emit a warn! at startup:

if public_url.contains("localhost") || public_url.contains("127.0.0.1") {
    warn!(
        public_url = %public_url,
        "GATEWAY_PUBLIC_URL looks like a loopback address.          Media attachments will not be reachable from other pods/containers.          Set GATEWAY_PUBLIC_URL to the gateway's cluster-internal Service URL          (e.g. http://openab-gateway:8080) or its external ingress URL."
    );
}

The Helm chart template (gateway.yaml) currently does not inject GATEWAY_PUBLIC_URL — so every Helm-based deployment silently inherits the broken default. The chart should either inject the Service's cluster-internal URL automatically or surface this as a required value.


🔴 New Issue (previously missed): HTTP response status not checked before storing media

In both line.rs and telegram.rs, the download response is stored without checking r.status().is_success(). If the platform API returns 401, 403, or any error response, the gateway stores the error page body as the media payload and emits an attachment URL pointing to it. OAB core then receives what looks like a valid attachment but contains an HTML error page.

line.rs — after if let Ok(r) = resp {, add:

if !r.status().is_success() {
    warn!(status = %r.status(), id = %msg.id, "LINE media download failed");
    continue;
}

telegram.rs — same pattern after the file download client.get(download_url).send().await:

if !r.status().is_success() {
    warn!(status = %r.status(), file_id = %file_id, "Telegram media download failed");
    continue;
}

The three original 🔴 issues (#1 TTL not enforced in handler, #2 memory unbounded by size, #3 reply_to cross-channel contamination) stand unchanged.

iamninihuang pushed a commit to iamninihuang/openab that referenced this pull request May 5, 2026
- TTL not enforced in media_handler: add TTL check before serving media
- Memory unbounded by size: add media_max_file_size limit (default 10MB)
- reply_to auto-fill cross-contamination: only auto-fill if channel.id is non-empty
- LINE media silently dropped: add warning log when access_token not configured
- Internal URL leaked to LLM: use generic message on download failure
@iamninihuang iamninihuang requested a review from Copilot May 5, 2026 06:16
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 9 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread gateway/src/main.rs
// Inbound media proxy config (Issue #690)
let public_url = std::env::var("GATEWAY_MEDIA_BASE_URL")
.or_else(|_| std::env::var("GATEWAY_PUBLIC_URL"))
.unwrap_or_else(|_| "http://localhost:8080".into());
Comment thread gateway/src/main.rs
Comment on lines +101 to +113
let last_event_id: Arc<Mutex<std::collections::HashMap<String, String>>> = Arc::new(Mutex::new(std::collections::HashMap::new()));

// Forward gateway events → OAB
let last_event_id_for_send = last_event_id.clone();
let send_task = tokio::spawn(async move {
loop {
tokio::select! {
Ok(event_json) = event_rx.recv() => {
// Track the last event ID sent to this client per channel
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&event_json) {
if let (Some(eid), Some(cid)) = (v["event_id"].as_str(), v["channel"]["id"].as_str()) {
let mut last = last_event_id_for_send.lock().await;
last.insert(cid.to_string(), eid.to_string());
Comment thread gateway/src/main.rs
Comment on lines +265 to +280
let public_url = std::env::var("GATEWAY_MEDIA_BASE_URL")
.or_else(|_| std::env::var("GATEWAY_PUBLIC_URL"))
.unwrap_or_else(|_| "http://localhost:8080".into());
let media_ttl = std::env::var("GATEWAY_MEDIA_STORE_TTL")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(300);
let media_max_entries = std::env::var("GATEWAY_MEDIA_STORE_MAX_ENTRIES")
.ok()
.and_then(|v| v.parse::<usize>().ok())
.unwrap_or(1000);
// Issue #690 review fix: per-file size limit to prevent OOM
let media_max_file_size = std::env::var("GATEWAY_MEDIA_MAX_FILE_SIZE")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.unwrap_or(10 * 1024 * 1024); // 10MB default
Comment thread gateway/src/main.rs
Comment on lines +244 to +247
axum::response::Response::builder()
.header("content-type", mime)
.body(axum::body::Body::from(data))
.unwrap()
Comment on lines +98 to +101
let mut text = msg
.text
.clone()
.unwrap_or_else(|| msg.caption.clone().unwrap_or_default());
Comment on lines +129 to +156
// Issue #690 review fix: Check file size before downloading
let content_length = r
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);

if content_length > state.media_max_file_size {
warn!(
size = content_length,
max = state.media_max_file_size,
id = %file_id,
"Telegram media too large, skipping"
);
} else {
let mime = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(if m_type == "image" {
"image/jpeg"
} else {
"audio/ogg"
})
.to_string();

if let Ok(data) = r.bytes().await {
Comment thread gateway/src/adapters/line.rs Outdated
Comment on lines +108 to +137
// Issue #690 review fix: Check file size before downloading
let content_length = r
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0);

if content_length > state.media_max_file_size {
warn!(
size = content_length,
max = state.media_max_file_size,
id = %msg.id,
"LINE media too large, skipping"
);
continue;
}

let mime = r
.headers()
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or(if msg.message_type == "image" {
"image/jpeg"
} else {
"audio/x-m4a"
})
.to_string();

if let Ok(data) = r.bytes().await {
Comment on lines +181 to +186
} else if msg.message_type != "text" {
// Issue #690 review fix: Warn when media message is dropped due to missing access_token
warn!(
msg_type = %msg.message_type,
"LINE media message dropped (access_token not configured)"
);
Comment thread docs/config-reference.md
|----------|-------------|---------|
| `GATEWAY_MEDIA_BASE_URL` | Public base URL for media proxy (e.g. `https://gateway.example.com`). | `http://localhost:8080` |
| `GATEWAY_MEDIA_STORE_TTL` | Seconds to keep media in memory before expiration. | `300` |
| `GATEWAY_MEDIA_STORE_MAX_ENTRIES` | Maximum number of media items to keep in memory. | `1000` |
@iamninihuang
Copy link
Copy Markdown
Contributor Author

經過幾次來回,發現這個 PR 不太好處理,仍然有多個地方需要修正,想請問還是先Close這個 PR,日後再戰?

@wangyuyan-agent
Copy link
Copy Markdown
Contributor

Don't close this — you're essentially done.

Five rounds of fixes, all high-severity issues resolved. The only remaining item is a one-liner: a startup warn! when GATEWAY_PUBLIC_URL looks like a loopback address, so Helm users get a clear signal instead of silent media failures.

Add that and I'll approve. The hard work is already done.

wangyuyan-agent
wangyuyan-agent previously approved these changes May 5, 2026
Copy link
Copy Markdown
Contributor

@wangyuyan-agent wangyuyan-agent left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All issues resolved — approved. Good work pushing this through.

@chaodu-agent
Copy link
Copy Markdown
Collaborator

🟡 Mostly Good — Media proxy architecture is sound, but has code quality issues that should be addressed

What problem does it solve? The Custom Gateway only supports text messages. Users on LINE/Telegram cannot send images or voice messages to the AI agent. This PR adds an in-memory media proxy that downloads platform media (which requires auth) and serves it at a temporary HTTP endpoint for OAB core to fetch.

How?

  1. Gateway downloads media from LINE Content API / Telegram getFile API
  2. Stores binary data in an in-memory HashMap<UUID, (Vec<u8>, mime, Instant)> with TTL sweep
  3. Serves at /media/{uuid} endpoint
  4. Adds attachments field to GatewayEvent schema
  5. OAB core (src/gateway.rs) builds ContentBlocks from attachments (image → base64 encode, audio → STT transcribe)

Considered alternatives? The PR description references Issue #690 which proposed this "Gateway Media Proxy" approach. This avoids changes to OAB core's media handling — the gateway presents media as standard URLs, same pattern as Discord CDN.

Review Details

🟢 INFO — Done well

  • Security: File size limit (media_max_file_size, default 10MB), max entries cap, TTL expiry — prevents OOM
  • Graceful degradation: If media download fails, logs warning and continues (no crash)
  • Schema backward compat: attachments field uses #[serde(default, skip_serializing_if = "Vec::is_empty")] — existing clients unaffected
  • Localhost warning: Warns operators when GATEWAY_MEDIA_BASE_URL looks like loopback
  • Auto-fill reply_to: Smart fallback for LINE reply token dispatch when agent omits event ID

🔴 SUGGESTED CHANGES

  1. Inconsistent indentation in LINE adapter (lines ~110-150): The content_length check and subsequent code has extra indentation (8 spaces inside a block that should be 4). This looks like a copy-paste artifact. The Telegram adapter has the same issue. Please fix indentation to match the surrounding code.

  2. continue inside Telegram webhook handler: The Telegram webhook() function processes a single update.message (not a loop), but the media download path uses continue (lines ~100-110 in telegram.rs diff). This would be a compile error unless there's an enclosing loop I'm not seeing. Please verify this compiles — if the continue is inside a loop {} or for that's not visible in the diff context, clarify; otherwise this is a bug.

  3. Duplicated media proxy logic: The LINE and Telegram adapters have nearly identical media download → store → create attachment logic (~50 lines each). Extract a shared helper like proxy_media(state, data, mime, msg_type, msg_id) -> Option<Attachment> to reduce duplication and ensure consistent behavior.

🟡 NIT — Non-blocking

  1. std::sync::Mutex for MediaStore: The store is accessed from async contexts. While unwrap_or_else(|e| e.into_inner()) handles poisoning, consider using tokio::sync::Mutex or parking_lot::Mutex for consistency with the rest of the codebase. The current approach works but holds the lock across .insert() which is fine for small maps.

  2. Unrelated formatting changes: ~100 lines of the diff are cargo fmt reformatting of src/cron.rs (struct initializers, function signatures). These should ideally be in a separate commit or PR to keep the diff focused. Not blocking but makes review harder.

  3. #[allow(dead_code)] additions in feishu.rs/googlechat.rs: These suppress warnings for fields that are deserialized but never read. Fine as a quick fix, but consider if these fields should be used or if the structs should use #[allow(dead_code)] at the struct level instead of per-field.

Verdict: The architecture is solid and well-thought-out. Items #1 and #2 need verification/fix before merge. Item #3 is a strong suggestion for maintainability but not strictly blocking.

@masami-agent
Copy link
Copy Markdown
Contributor

PR Review: #726

Based on commit: 804d643f9b25870324ceb7b68af1e0f438166246

Summary

  • Problem: Custom Gateway (LINE/Telegram) only supports text messages; images and audio are dropped.
  • Approach: In-memory media proxy that downloads platform media (which requires auth) and serves it at a temporary local HTTP endpoint, allowing the AI agent to fetch media via standard URLs.
  • Risk level: Medium — new runtime state (in-memory store), new HTTP endpoint, new external API calls.

Core Assessment

  1. Problem clearly stated: ✅ (linked to feat(gateway): support images and audio for LINE/Telegram #690, clear description)
  2. Approach appropriate: ✅ (mirrors Discord adapter pattern, proxy is necessary because platform APIs require auth)
  3. Alternatives considered: N/A (approach was discussed in Discord thread)
  4. Best approach for now: ✅ (in-memory store with TTL is simple and sufficient for the current scale)

Findings

🔴 Critical: continue statements outside a loop in gateway/src/adapters/telegram.rs

Where: gateway/src/adapters/telegram.rs lines ~121 and ~129

What's wrong: The webhook function is NOT inside a loop, yet there are two continue statements:

if !resp.status().is_success() {
    warn!(status = %resp.status(), id = %file_id, "Telegram getFile failed");
    continue;  // ← ERROR: not inside a loop
}

and

if !r.status().is_success() {
    warn!(status = %r.status(), id = %file_id, "failed to download Telegram media");
    continue;  // ← ERROR: not inside a loop
}

Why it matters: This is a compile error in Rust. It is not caught by CI because ci.yml only compiles the main openab crate (triggered by src/** changes), not the gateway/ crate. This will fail when the gateway binary is actually built.

Fix: Replace continue; with early-return or restructure as if/else:

// Option A: just don't proceed (already inside nested if-let, so just do nothing)
if !resp.status().is_success() {
    warn!(status = %resp.status(), id = %file_id, "Telegram getFile failed");
    // Remove continue — the nested if/else structure already handles this
}

Or restructure the entire block to avoid deep nesting (see 🟡 suggestion below).


🔴 Critical: Inconsistent indentation suggests copy-paste error in file size check

Where: gateway/src/adapters/telegram.rs lines ~132-148, gateway/src/adapters/line.rs lines ~110-126

What's wrong: The content_length check block has inconsistent indentation (extra leading spaces compared to surrounding code). More importantly, in the Telegram adapter, the file size check is placed AFTER the continue statement on the error path, meaning if the continue issue is fixed by removing it, the code flow is correct. But the indentation mismatch suggests this was pasted without proper integration.

Fix: Fix indentation to match the surrounding code level. Run cargo fmt on the gateway crate.


🟡 Minor: Deep nesting in media download logic (both adapters)

Where: gateway/src/adapters/telegram.rs lines 114-195, gateway/src/adapters/line.rs lines 93-175

What's wrong: The media download logic is nested 7-8 levels deep with if let Ok(...) chains. This makes the code hard to read and error-prone (as evidenced by the continue bug above).

Why it matters: Maintainability. Future contributors will struggle to understand the control flow.

Fix: Extract the media download logic into a helper function, e.g.:

async fn download_and_proxy_media(
    state: &AppState,
    file_id: &str,
    m_type: &str,
    download_url: &str,
) -> Option<Attachment> {
    // flat early-return style
}

🟡 Minor: reqwest::Client::new() created per-request in adapters

Where: gateway/src/adapters/line.rs line 100, gateway/src/adapters/telegram.rs line 116

What's wrong: A new reqwest::Client is created for every incoming media message. reqwest::Client internally manages a connection pool, so creating a new one per request defeats connection reuse and adds overhead.

Fix: Store a shared reqwest::Client in AppState (or use a LazyLock like the main crate does in src/media.rs).


🟡 Minor: content_length check is unreliable as a size gate

Where: Both adapters, the content_length header check

What's wrong: content_length defaults to 0 when the header is missing. A value of 0 will always pass the size check, meaning files without a Content-Length header bypass the limit entirely. The actual bytes are then downloaded without limit (until r.bytes().await completes).

Fix: Consider also checking data.len() after download and discarding if over limit:

if let Ok(data) = r.bytes().await {
    if data.len() as u64 > state.media_max_file_size {
        warn!("downloaded media exceeds size limit");
        // don't store
        continue; // or return, depending on context
    }
    // ... store
}

🟡 Minor: std::sync::Mutex used for MediaStore in async context

Where: gateway/src/main.rs line 36 (pub type MediaStore = Arc<std::sync::Mutex<...>>)

What's wrong: Using std::sync::Mutex in async code can block the tokio runtime if the lock is held across an .await point. Currently the lock is acquired and released quickly (no await while held), so it works. But it's fragile — a future refactor could accidentally hold it across an await.

Fix: This is acceptable for now since the critical section is short (just insert/get). Add a comment noting this constraint, or switch to tokio::sync::Mutex for safety.


🟢 Info: Good design decisions

  • TTL-based cleanup with background sweep is clean and simple
  • skip_serializing_if = "Vec::is_empty" on attachments maintains backward compatibility
  • The Attachment schema is well-structured and extensible
  • Reusing the existing media::download_and_encode_image / media::download_and_transcribe in src/gateway.rs is the right call — no duplication
  • The reply_to auto-fill with channel-scoped tracking is a nice UX improvement
  • Loopback address warning at startup is helpful for deployment debugging
  • The cron.rs changes are purely cargo fmt reformatting — no logic changes, clean

Review Summary (Traffic Light)

🟢 INFO

  • Well-structured feature with clear separation: gateway proxy (download + serve) ↔ core bridge (fetch + encode for LLM)
  • Schema is backward-compatible (optional field, skip when empty)
  • Good test coverage reported (96 tests pass, clippy clean)
  • Docs updated for LINE, Telegram, and config reference

🟡 NIT

  • Deep nesting in media download logic — extract helper functions
  • reqwest::Client::new() per request — use shared client
  • content_length fallback to 0 bypasses size check when header missing
  • std::sync::Mutex in async context — acceptable but fragile

🔴 SUGGESTED CHANGES

  • continue outside loop in Telegram adapter — this is a compile error that CI doesn't catch because the gateway crate isn't in the PR CI pipeline. Must be fixed before merge.
  • Indentation inconsistency in file size check blocks — run cargo fmt on gateway crate

Verdict

REQUEST_CHANGES — The continue statements in telegram.rs are invalid Rust (not inside a loop). This will fail to compile when the gateway binary is built. Fix those, run cargo fmt on the gateway crate, and this is ready for another look.

@masami-agent
Copy link
Copy Markdown
Contributor

Follow-up: CI Blind Spot Confirmation

After a second pass, I verified the continue issue with rustc directly:

error[E0268]: `continue` outside of a loop
  --> <anon>:1:23
   |
   | fn main() { if true { continue; } }
   |                       ^^^^^^^^ cannot `continue` outside of a loop

Why CI does not catch this:

The gateway/ crate is a standalone binary with its own Cargo.toml — it is NOT part of a Cargo workspace. The PR CI pipeline (ci.yml) only compiles the root openab crate. The gateway is only built during release (build-gateway.yml, triggered by gateway-v* tags).

This means any compile error in gateway/src/ will pass all PR checks and only be discovered at release time.

Suggestion: Consider adding a gateway-check job to ci.yml (triggered on gateway/** path changes) that runs cargo check inside the gateway/ directory. This would catch issues like this before merge.


Note: The LINE adapter's continue statements are valid — they are inside the for event in webhook_body.events loop. Only the Telegram adapter has this issue (lines 121 and 129).

iamninihuang and others added 6 commits May 6, 2026 14:15
…bdev#726)

- Update GATEWAY_MEDIA_BASE_URL default in docs
- Add GATEWAY_MEDIA_STORE_MAX_ENTRIES limit to prevent OOM
- Update Axum route syntax to 0.8
- Add rationale comments for reply_to auto-fill
- Fix indentation in Telegram adapter
- Fix clippy warnings in cron.rs
- TTL not enforced in media_handler: add TTL check before serving media
- Memory unbounded by size: add media_max_file_size limit (default 10MB)
- reply_to auto-fill cross-contamination: only auto-fill if channel.id is non-empty
- LINE media silently dropped: add warning log when access_token not configured
- Internal URL leaked to LLM: use generic message on download failure
- Add startup warning when GATEWAY_PUBLIC_URL contains localhost/127.0.0.1
- Add HTTP status checks before storing media in LINE/Telegram adapters
@iamninihuang iamninihuang force-pushed the feat/690-gateway-media-support branch from aeeb4bd to f15494b Compare May 6, 2026 06:42
iamninihuang added 2 commits May 6, 2026 15:13
…oxy support

- Remove duplicate GwAttachment/Attachment definitions
- Fix syntax errors in LINE adapter
- Update Attachment schema to support both URL proxy and legacy base64
- Update Feishu and Telegram adapters to match new schema
- Fix main crate gateway display logic for optional URLs
@iamninihuang
Copy link
Copy Markdown
Contributor Author

iamninihuang commented May 6, 2026

原本修的差不多了,但main branch 更新造成衝突,因為attachment其它元件也有共用,不太好解,
故我打算關閉這個 PR , 會再取最新,再重新 PR。

@masami-agent
Copy link
Copy Markdown
Contributor

Re-review: PR #726 (commit d94d437)

Thanks for addressing the feedback! The continue issue in telegram.rs is fixed — replaced with proper if/else branching. ✅

However, there is a new critical issue:


🔴 Critical: Unresolved merge conflict markers in gateway/src/main.rs

Where: gateway/src/main.rs lines 261, 276, 292

What's wrong: The file contains literal merge conflict markers that were not resolved:

<<<<<<< Updated upstream
    let listen_addr = ...
    let ws_token = ...
    // Inbound media proxy config (Issue #690)
    let public_url = ...
    if public_url.contains("localhost") || public_url.contains("127.0.0.1") {
        warn!(...);
=======
    // Load configuration from environment
    let bot_token = std::env::var("TELEGRAM_BOT_TOKEN").expect(...)
    let secret_token = ...
    let ws_token = ...
    ...
>>>>>>> Stashed changes
    }

The commit message says "resolve merge conflict in main.rs after stash pop" but the conflict was not actually resolved — the markers are still in the file. This will not compile.

Fix: Resolve the conflict properly. It looks like both sides need to be merged:

  • The "Updated upstream" side has the media proxy config (public_url, loopback warning)
  • The "Stashed changes" side has the env var loading (bot_token, secret_token, webhook paths, cloudflare tunnel)

Both are needed. Remove the conflict markers and combine the code.


✅ Fixed from previous review

  • continue outside loop in telegram.rsfixed (proper if/else now)
  • Schema updated: url is now Option<String>, new data field added — OAB side handles this correctly

🟢 Info: Other observations on new commits

  • Content struct now has an attachments field — the OAB side deserializes it but only uses event.attachments (top-level). This is fine for now.
  • Attachment.url changed from String to Option<String> — OAB side uses .as_deref().unwrap_or("") which is correct.

Verdict

REQUEST_CHANGES — The merge conflict markers in gateway/src/main.rs must be resolved. Same CI blind spot applies: the gateway crate is not compiled in PR CI, so this won't be caught automatically.

@masami-agent
Copy link
Copy Markdown
Contributor

Hi @iamninihuang — thanks for fixing the continue issue! 🎉

One more thing to resolve: gateway/src/main.rs still has unresolved merge conflict markers from the stash pop (lines 261–292).

Here's what's happening:

<<<<<<< Updated upstream       ← side A: media proxy config
    let listen_addr = ...
    let ws_token = ...
    let public_url = ...       ← new media proxy env vars
    if public_url.contains("localhost") { warn!(...); }
=======                        ← side B: original env loading
    let bot_token = ...        ← duplicates line 326 below
    let secret_token = ...     ← duplicates line 327 below
    let ws_token = ...
    let line_channel_secret = ...  ← duplicates line 341 below
    let listen_addr = ...
    let telegram_webhook_path = ...  ← duplicates line 330 below
    ...
>>>>>>> Stashed changes

How to fix: You likely want side A only (the "Updated upstream" block), because:

  • The env vars in side B (TELEGRAM_BOT_TOKEN, LINE_CHANNEL_SECRET, webhook paths, etc.) are already loaded later in the file (lines 326–345).
  • Side B also uses .expect() which would panic if TELEGRAM_BOT_TOKEN is unset, but the existing code at line 326 uses .ok() (graceful — Telegram is optional).
  • cloudflare_tunnel_url (referenced at line 313) is only defined in side B, so you'll need to keep that one line: let cloudflare_tunnel_url = std::env::var("CLOUDFLARE_TUNNEL_URL").ok();

Suggested resolution:

    let listen_addr = std::env::var("GATEWAY_LISTEN").unwrap_or_else(|_| "0.0.0.0:8080".into());
    let ws_token = std::env::var("GATEWAY_WS_TOKEN").ok();

    // Inbound media proxy config (Issue #690)
    let public_url = std::env::var("GATEWAY_MEDIA_BASE_URL")
        .or_else(|_| std::env::var("GATEWAY_PUBLIC_URL"))
        .unwrap_or_else(|_| "http://localhost:8080".into());

    // Warn if public_url looks like a loopback address
    if public_url.contains("localhost") || public_url.contains("127.0.0.1") {
        warn!(
            public_url = %public_url,
            "GATEWAY_PUBLIC_URL looks like a loopback address. ..."
        );
    }

    // Optional Cloudflare Tunnel domain for logging
    let cloudflare_tunnel_url = std::env::var("CLOUDFLARE_TUNNEL_URL").ok();

Then delete the conflict markers (<<<<<<<, =======, >>>>>>>). After that, run cargo check inside the gateway/ directory to verify it compiles.

Let me know if you have any questions!

@iamninihuang
Copy link
Copy Markdown
Contributor Author

原本修的差不多了,但main branch 更新造成衝突,因為attachment其它元件也有共用,不太好解,
故我打算關閉這個 PR , 會再取最新,再重新 PR。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(gateway): support images and audio for LINE/Telegram

8 participants