Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
179 changes: 162 additions & 17 deletions crates/webhook-bridge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,37 @@ pub fn signed(content: &str, name: &str) -> String {
format!("{SIGNED_PREFIX}{name}\n{content}")
}

// ── Marking it processed (#410) ──────────────────────────────────────────────
//
// The room's notice is the mail notification; the bridge's queue is the inbox.
// A session that reads a notice of its own operation is to open the inbox and
// mark it, there and then — a reminder placed anywhere but on the notice itself
// was read past (2026-10-13/14, pending grew to 377). The app still marks
// nothing (#180): the line only names the id the session hands `mark_processed`.

/// The line every relayed notice carries, before the event id.
pub const MARK_PROMPT: &str = "If this is your own operation, mark it processed now: event_id=";

/// A notice with the mark-processed line for `event_id`.
///
/// The line goes just before the URL when the notice ends with one — the
/// bridge's summary does — and at the end otherwise.
pub fn prompted(content: &str, event_id: &str) -> String {
let line = format!("{MARK_PROMPT}{event_id}");
let body = content.trim_end_matches(['\r', '\n']);
match body.rsplit_once('\n') {
Some((head, last)) if is_url(last) => format!("{head}\n{line}\n{last}"),
None if is_url(body) => format!("{line}\n{body}"),
_ => format!("{body}\n{line}"),
}
}

fn is_url(line: &str) -> bool {
let line = line.trim();
(line.starts_with("https://") || line.starts_with("http://"))
&& !line.contains(char::is_whitespace)
}

/// One event waiting to be posted.
struct Waiting {
event: Event,
Expand Down Expand Up @@ -360,13 +391,17 @@ impl Notices {
self.waiting.drain(..).map(Self::notice).collect()
}

/// The notice as posted: signed when a name was read, and carrying the
/// mark-processed line either way.
fn notice(one: Waiting) -> Event {
match one.name {
Some(name) => Event {
content: signed(&one.event.content, &name),
event_id: one.event.event_id,
},
None => one.event,
let content = prompted(&one.event.content, &one.event.event_id);
let content = match one.name {
Some(name) => signed(&content, &name),
None => content,
};
Event {
content,
event_id: one.event.event_id,
}
}
}
Expand Down Expand Up @@ -508,7 +543,10 @@ mod tests {
// GitHub keeps the trailing newline, and Windows writes \r\n.
assert_eq!(signature("本文\r\n— Lay\r\n\r\n"), Some("Lay".to_string()));
// A name with a space in it is still one name.
assert_eq!(signature("x\n— Claude Lin "), Some("Claude Lin".to_string()));
assert_eq!(
signature("x\n— Claude Lin "),
Some("Claude Lin".to_string())
);
// A body that is only the signature is signed.
assert_eq!(signature("— Lin"), Some("Lin".to_string()));
}
Expand Down Expand Up @@ -590,7 +628,11 @@ mod tests {
] {
let mut payload = json!({ "action": action });
payload[key] = json!({ "body": body });
assert_eq!(signer(&stored(kind, payload)), Some("Lin".to_string()), "{kind}");
assert_eq!(
signer(&stored(kind, payload)),
Some("Lin".to_string()),
"{kind}"
);
}
// An issue_comment carries the issue too, whose body is not this one.
let both = json!({
Expand Down Expand Up @@ -642,6 +684,11 @@ mod tests {
}
}

/// The event as the room is handed it, unsigned.
fn posted(id: &str, content: &str) -> Event {
event(id, &prompted(content, id))
}

fn ids(events: &[Event]) -> Vec<&str> {
events.iter().map(|e| e.event_id.as_str()).collect()
}
Expand All @@ -653,7 +700,7 @@ mod tests {
assert_eq!(notices.push(event("p", "[push] repo"), true, now), None);
assert_eq!(notices.next_deadline(), None);
let due = notices.due(now);
assert_eq!(due, vec![event("p", "[push] repo")]);
assert_eq!(due, vec![posted("p", "[push] repo")]);
}

#[test]
Expand All @@ -662,7 +709,10 @@ mod tests {
let now = Instant::now();
let content = summary("issue_comment", "created");
let (id, line) = notices.push(event("c", &content), true, now).expect("read");
assert_ne!(id, INITIALIZE_ID, "a request id must not be taken for initialize's answer");
assert_ne!(
id, INITIALIZE_ID,
"a request id must not be taken for initialize's answer"
);
assert_eq!(parsed(&line)["params"]["arguments"]["event_id"], "c");
assert!(notices.due(now).is_empty(), "waits for its body");

Expand All @@ -673,7 +723,10 @@ mod tests {
notices.answer(id, None, Some(&answer));
let due = notices.due(now);
assert_eq!(due.len(), 1);
assert_eq!(due[0].content, format!("署名: Lay\n{content}"));
assert_eq!(
due[0].content,
format!("署名: Lay\n{}", prompted(&content, "c"))
);
}

#[test]
Expand All @@ -685,28 +738,34 @@ mod tests {
let mut notices = Notices::new();
let (id, _) = notices.push(event("a", &content), true, now).expect("read");
notices.answer(id, Some("Failed to reach worker"), None);
assert_eq!(notices.due(now), vec![event("a", &content)]);
assert_eq!(notices.due(now), vec![posted("a", &content)]);

// Unsigned body.
let (id, _) = notices.push(event("b", &content), true, now).expect("read");
let answer = stored("issues", json!({ "action": "opened", "issue": { "body": "x" } }));
let answer = stored(
"issues",
json!({ "action": "opened", "issue": { "body": "x" } }),
);
notices.answer(id, None, Some(&answer));
assert_eq!(notices.due(now), vec![event("b", &content)]);
assert_eq!(notices.due(now), vec![posted("b", &content)]);

// No answer at all: out at the deadline, unsigned.
notices.push(event("c", &content), true, now).expect("read");
assert_eq!(notices.next_deadline(), Some(now + SIGNATURE_WAIT));
assert!(notices.due(now + SIGNATURE_WAIT / 2).is_empty());
assert_eq!(notices.due(now + SIGNATURE_WAIT), vec![event("c", &content)]);
assert_eq!(
notices.due(now + SIGNATURE_WAIT),
vec![posted("c", &content)]
);

// Not sent.
let (id, _) = notices.push(event("d", &content), true, now).expect("read");
notices.abandon(id);
assert_eq!(notices.due(now), vec![event("d", &content)]);
assert_eq!(notices.due(now), vec![posted("d", &content)]);

// Before the session is open, nothing is asked.
assert_eq!(notices.push(event("e", &content), false, now), None);
assert_eq!(notices.due(now), vec![event("e", &content)]);
assert_eq!(notices.due(now), vec![posted("e", &content)]);
}

#[test]
Expand Down Expand Up @@ -748,4 +807,90 @@ mod tests {
assert_eq!(ids(&notices.drain()), vec!["1", "2"]);
assert!(notices.due(now + SIGNATURE_WAIT).is_empty());
}

// ── the mark-processed line (#410) ────────────────────────────────────

fn prompts(content: &str) -> Vec<&str> {
content
.lines()
.filter(|l| l.starts_with(MARK_PROMPT))
.collect()
}

#[test]
fn the_mark_line_goes_just_before_the_url() {
let content = summary("issues", "closed");
let out = prompted(&content, "delivery-9");
assert_eq!(
out,
"[issues] Liplus-Project/pullcept\naction: closed\n#1 title\nby liplus-lin-lay\n\
If this is your own operation, mark it processed now: event_id=delivery-9\n\
https://github.com/x"
);
}

#[test]
fn without_a_url_the_mark_line_goes_at_the_end() {
assert_eq!(
prompted("[push] repo\nby someone\n", "p"),
"[push] repo\nby someone\nIf this is your own operation, mark it processed now: event_id=p"
);
assert_eq!(
prompted("[push] repo", "p"),
"[push] repo\nIf this is your own operation, mark it processed now: event_id=p"
);
// A last line that only mentions a URL is not the URL line.
assert!(prompted("see https://x y", "p").ends_with("event_id=p"));
}

#[test]
fn every_notice_carries_the_mark_line_once_with_its_own_id() {
let now = Instant::now();
let comment = summary("issue_comment", "created");
let mut notices = Notices::new();
// Signed.
let (id, _) = notices.push(event("s", &comment), true, now).expect("read");
let answer = stored(
"issue_comment",
json!({ "action": "created", "comment": { "body": "x\n— Lin" } }),
);
notices.answer(id, None, Some(&answer));
// Unsigned, with no read.
notices.push(event("u", "[push] repo"), true, now);
let mut out = notices.due(now);
// Held when the bridge ends.
notices.push(event("h", &comment), true, now).expect("read");
out.extend(notices.drain());

assert_eq!(ids(&out), vec!["s", "u", "h"]);
for one in &out {
let id = one.event_id.as_str();
assert_eq!(
prompts(&one.content),
vec![format!("{MARK_PROMPT}{id}").as_str()],
"{id}"
);
}
}

#[test]
fn a_signed_notice_still_opens_with_the_signature() {
let now = Instant::now();
let comment = summary("issue_comment", "created");
let mut notices = Notices::new();
let (id, _) = notices.push(event("s", &comment), true, now).expect("read");
let answer = stored(
"issue_comment",
json!({ "action": "created", "comment": { "body": "x\n— Lay" } }),
);
notices.answer(id, None, Some(&answer));
let due = notices.due(now);
let mut lines = due[0].content.lines();
assert_eq!(lines.next(), Some("署名: Lay"));
assert_eq!(
lines.next(),
Some("[issue_comment] Liplus-Project/pullcept")
);
assert_eq!(due[0].content.lines().last(), Some("https://github.com/x"));
}
}
7 changes: 7 additions & 0 deletions docs/1-room.md
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,13 @@ webhook を CLI セッションではなく**アプリ本体**で受け、部屋

**アプリはイベントを `mark_processed` しない(#180)。** 部屋へ出すのは見せることであり、処理済みにするかどうかはそのイベントを実際に扱ったセッションが決める。未処理の一覧を片付けるのもセッション側であり、アプリは何もしない。`github-webhook-mcp` の既読は全体で一つであるため、アプリが印を付けると、poll で未処理を読むセッション(Desktop の Li+ など)の一覧からそのイベントが消える——2026-09-27 に実際に観測した(#180 premise)。未処理のまま残しても部屋に二度載ることは無い:Worker が broadcast するのは取り込みの瞬間の一度だけであり、WebSocket の接続時に送るのは `{status: "connected"}` だけで、未処理の再送は無い(`github-webhook-mcp` の `worker/src/store.ts`、#180 premise)。id を持たない push は出さない——ログでも、印を付けるセッションに対しても、そのイベントを名指せないからである。

**部屋に流す通知には、既読を促す英語の一行を必ず付ける(#410)。** 形は `If this is your own operation, mark it processed now: event_id=<イベントの id>` に固定する(英語はマスター指定)。部屋の通知はメールの通知であり、キューは受信箱である——通知を見たセッションは、それが自分の作業の知らせならその場で受信箱を開いて `mark_processed` する(マスターの整理、2026-10-14 の部屋)。自分の作業でない知らせは既読にしない。2026-10-13〜14 は Lin・Lay が通知を読んで済ませ、キューの未処理が 377 件まで溜まった。毎ターンの hook も催促していたが、通知とは別の場所に出るため読み流された。そこで案内は通知そのものに載せる。

- **置き場は URL の行の直前である。** 要約の最終行が URL(`https://` か `http://` で始まり空白を含まない行)なら、その前に入れる。そうでなければ末尾に足す。
- **どの通知にもちょうど一度付く。** 署名の有無、読み待ちが期限で切れたもの、ブリッジの終わりに出すものを問わない。署名のある通知は `署名: <名前>` が先頭の行のままである。
- **アプリは今も既読にしない(#180)。** 一行は印を付けるセッションに id を渡すだけである。
- **付けるのは `crates/webhook-bridge` の `prompted` であり、`Notices` が通知を出すときに通す。** テストもそこに置く。

**画面では畳む。** `mcp` アカウントの行は畳んだ形で出し、クリックで開く。**同じサーバの連続した行は一つの畳みにまとめる**——他の発言者の行が入るまでの連なりを「〈アカウントの名前〉 N 件」として畳み、開くと各件が普通の行として並ぶ。別のサーバが言えば別の畳みになる。まとめるのは表示だけであり、部屋・ログ・端末への入力では一件ずつ別の `post` のままである。

- **判定は種別で行う(#193)。** 行が運ぶアカウント id を画面のアカウント一覧で引き、種別が `mcp` なら畳む。以前は名前(`webhook`)で判定していた。ログがアカウントを運ぶようになったため(上記「部屋のログ」)、ライブの行と読み戻した行は同じ規則で畳まれ、改名しても、同じ名前の別のアカウントが居ても、畳み方は動かない。削除されたアカウントの行は、もう `mcp` のアカウントではないため畳まれない。
Expand Down
1 change: 1 addition & 0 deletions docs/4-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ app-server 方式の Codex の席について、席の server がアプリへ送
- 発言の端末への入力(#183 / #195。`deliver()` の後段で、起動の台帳から引いた発言者以外の各セッションの端末へ一行目の札と本文を打ち、間を置いて `\r` を別に送ること、札の `role`(画面のコマンドだけが `admin`、通知は `mcp`、アプリ自身の知らせは `app`(#294)、ソケットは名乗ったアカウントの種別で `admin` と `mcp` は `unknown`)、部屋から `post` フレームを送らないこと、送信キーを持たない本文、一本の列で順に打つこと、端末ペインのキーから打ちかけを読み保留した発言を届いた順に打つこと、札と `role` の重みと二行目以降の札の形を本文と読むことを述べる作法、サイドカーの channel の宣言と起動の行の `--dangerously-load-development-channels` を外したこと)
- webhook の通知(#169。同梱の `github-webhook-mcp` ブリッジをアプリ自身の MCP クライアントとして起動、標準入力の終わりで終わる起動形、イベントが届くまで道具を呼ばない待ち受け、AI セッションの座る部屋すべてへの一件ずつの `post`、席を取らない発言者(#193 からはサーバの `mcp` アカウント)、床が問わないこと、座る部屋が無いときに出さないこと、イベントに印を付けないこと(#180)、連なりを一つにまとめて畳む表示)
- 書き手の署名(#270 / #269。GitHub に本文つきで書くときに最終行へ `— <名前>` を置くよう言う作法の一項と、署名の無い書き込みの扱い、本文を今書いた種類と action の通知だけを `get_event` で読み直すこと、署名を読めた通知の先頭の `署名: <名前>`、読めないときの目印の無い通知、5 秒で区切る読み待ちと届いた順を保つこと、`mark_processed` しないこと)
- 既読を促す一行(#410。部屋に流すすべての通知に `If this is your own operation, mark it processed now: event_id=<id>` を URL の行の直前、URL が無ければ末尾に一度だけ付けること。署名の行は先頭のまま)
- ローカル MCP サーバを種別 `mcp` のアカウントとして持つこと(#193。エントリごとのアカウントの作成とその id、`config.json` の `server` による対応、サーバのアカウントとしての通知の発言、発言とログが運ぶアカウント、種別による畳みと #193 より前の行の名前による畳み、パネルの `MCP` の見出しと行の語、サーバの節のアカウントの窓への移動、決定でのサーバの欄の保存、エントリが在る間の削除の拒否、起動の拒否、項目の無い設定メニュー)
- フォームから作る `mcp` アカウント(#200。作成時にだけ出る種別 `MCP サーバ`、作成の窓のサーバの欄、決定でのエントリの書き込みとアカウントの保存とサーバの起動、アカウントの名前から決めて使われている名前と id を避けるエントリの名前、削除でのエントリの取り外しとサーバの停止と持っていた状態の破棄、作成後に種別を変えられないこと)
- 設定メニューの表示の項目(#194 / #66。会話の文字サイズ・端末の文字サイズ・UI の大きさの三つのピッカー、画面上の寸法の操作はここだけであること(#209 でヘッダの二つを外した)、キーで変えた会話の値がピッカーに出ること、変えた時点で効くこと、`:root` の `--ui-scale` と `font-size` による UI の大きさ、会話面の寸法の割り戻し、`80%`〜`150%` の梯子、`localStorage`(`pullcept.ui-scale`)保持、既定は現状維持)
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/webhook.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@
//! read, how the answer is read, and the order notices keep while one is read
//! are `webhook_bridge::Notices`.
//!
//! **Every notice carries one line naming its event id for `mark_processed`
//! (#410).** The session that reads a notice of its own operation is to mark it
//! there and then; the app itself still marks nothing (#180). Where the line
//! goes is `webhook_bridge::prompted`, applied by `Notices`.
//!
//! **What sessions already receive is unchanged.** A session that loads its own
//! `github-webhook-mcp` channel keeps it; this is a second path beside it, not a
//! replacement (#169 constraints).
Expand Down
Loading