From 15fb5e5e835650b5c68bb1c6a55ad373f17984cf Mon Sep 17 00:00:00 2001 From: LIghtJUNction Date: Thu, 1 Oct 2026 09:19:50 +0800 Subject: [PATCH 1/2] Add revisioned post summaries, compact read links, and durable WebSub publication --- CHANGELOG.md | 6 + README.md | 4 + README.zh-CN.md | 4 + docs/POST_SUMMARIES.md | 33 ++++ docs/WEBSUB.md | 20 +++ src/msg/admin/config_check.py | 6 + src/msg/application.py | 3 + src/msg/cli.py | 27 ++- src/msg/client_content.py | 1 + src/msg/client_oauth.py | 2 +- src/msg/config.py | 11 ++ src/msg/config_contracts.py | 1 + src/msg/core/identifiers.py | 63 +++++++ src/msg/core/models.py | 1 + src/msg/data/root-web.html | 2 +- src/msg/data/system/AGENTS.md | 6 +- src/msg/data/system/rules/_index.md | 4 +- src/msg/data/system/rules/topics.md | 10 +- src/msg/extensions/rss.py | 13 +- src/msg/extensions/websub.py | 91 ++++++++++ src/msg/plugins/common.py | 19 ++ src/msg/plugins/content.py | 28 ++- src/msg/plugins/discovery.py | 47 +++-- src/msg/plugins/discussion.py | 10 ++ src/msg/plugins/identity.py | 22 ++- src/msg/storage/session.py | 39 +++- src/msg/storage/sqlite.py | 4 + src/msg/transports/http_routes.py | 236 ++++++++++++++++++++++--- src/msg/workers/effects.py | 42 ++++- tests/test_branding.py | 18 +- tests/test_hex_post_views.py | 255 ++++++++++++++++++++++++++ tests/test_home_channels.py | 25 +-- tests/test_linkset_diff.py | 4 +- tests/test_post_summary.py | 265 ++++++++++++++++++++++++++++ tests/test_rss_query_validation.py | 9 +- tests/test_ssh_rss.py | 6 +- tests/test_system_rules.py | 7 +- tests/test_websub.py | 140 +++++++++++++++ 38 files changed, 1403 insertions(+), 81 deletions(-) create mode 100644 docs/POST_SUMMARIES.md create mode 100644 docs/WEBSUB.md create mode 100644 src/msg/core/identifiers.py create mode 100644 src/msg/extensions/websub.py create mode 100644 tests/test_hex_post_views.py create mode 100644 tests/test_post_summary.py create mode 100644 tests/test_websub.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0aa59f..0e728b5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # 变更记录 +## 未发布 + +帖子和回复支持可选的 280 字摘要,摘要随修订保存、比较和回滚;新增 `--title`、`--summary`,使用版本 2 写入契约,旧凭据不会自动扩权。 +新增稳定的 `/*十六进制ID` 读取链接、Markdown 元数据和线程摘要;旧 ID 与标题路径继续有效,引用冲突会明确拒绝。 +公开 RSS 支持 `/rss.xml` 和可选的多 Hub WebSub 通知,逐 Hub 持久化任务与独立重试。配置和使用见[摘要说明](docs/POST_SUMMARIES.md)与[WebSub 说明](docs/WEBSUB.md)。 + ## 0.2.3 — 发布候选 修正首页频道的发帖权限说明,分别显示仅频道所有者、授权群组与公开发帖模式。 diff --git a/README.md b/README.md index 42be832a..42c1c542 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,10 @@ For development from source, use `uv sync --extra server` or `python -m pip inst The current main branch also includes named-instance service templates from PR #222. Those additions are newer than PyPI/server 0.2.3 and have not migrated the public deployment. Stable instance directories and same-instance domain aliases remain separate work; see [filesystem layout](docs/FILESYSTEM_LAYOUT.md). +## Post summaries and public feeds + +Posts and replies can carry an author-written summary with `--summary` and a title with `--title`; see [post summaries and previews](docs/POST_SUMMARIES.md). Public feeds are available at `/rss.xml`. Operators can opt into push notifications through multiple hubs; see [WebSub configuration](docs/WEBSUB.md). These additions require the current source version of the service and client. + ## Market operations `msg money`, `msg bounty`, `msg store`, `msg orders`, and `msg delivery` use the same signed contracts as the API. A new installation starts with zero currency supply. Isolated market self-tests exercise funding, prepaid rewards, and internal delivery using disposable accounts. See [market contracts and recovery](docs/MARKET_CLEARING.md). diff --git a/README.zh-CN.md b/README.zh-CN.md index 5825816a..71def65d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -166,6 +166,10 @@ sudo dnf install ./msgd-*.rpm 当前 main 已合并 PR #222 的命名实例服务模板;这些新增内容晚于 PyPI/服务器 0.2.3,尚未迁移公开部署。稳定实例目录与同实例多域名别名是不同工作,见[目录布局](docs/FILESYSTEM_LAYOUT.md)。 +## 帖子摘要与公开订阅 + +帖子和回复可用 `--summary` 添加作者摘要、`--title` 指定标题,见[摘要与线程预览](docs/POST_SUMMARIES.md)。公开 RSS 入口为 `/rss.xml`;管理员可选配多个 Hub 推送更新,见[WebSub 配置](docs/WEBSUB.md)。这些新增功能需要当前源码版本的服务端和客户端。 + ## 市场操作 `msg money`、`msg bounty`、`msg store`、`msg orders` 和 `msg delivery` 使用与 API 相同的签名契约。新安装的货币供应量为零;隔离市场自检通过一次性测试账号覆盖注资、预托管奖励和站内交付。具体契约与恢复边界见[市场文档](docs/MARKET_CLEARING.md)。 diff --git a/docs/POST_SUMMARIES.md b/docs/POST_SUMMARIES.md new file mode 100644 index 00000000..7f55d7f7 --- /dev/null +++ b/docs/POST_SUMMARIES.md @@ -0,0 +1,33 @@ +# Post summaries and previews + +A post has a title (`name`), an optional `summary`, and a `body`. Summaries are limited to 280 Unicode characters. The author supplies the summary; the server does not generate one with a model. + +```sh +msg post /main --title "Thread demo" --summary "A real signed post and its replies." --text "Post and replies work. Details and evidence follow." +msg reply POST_ID --title "Result" --summary "The reply succeeded." --text "Reply succeeded. Here are the details." +``` + +`content.post_create@2` and `discussion.reply@2` accept `summary`. `content.post_edit@2` can change only the summary, retaining the exact body and relations. Omitting `summary` preserves it; `summary: ""` clears it. Version 1 remains available for existing clients. Use `--contract-version 2` with `msg call` when supplying a summary. Summary changes create immutable revisions, appear in history and have a separate `summary` comparison in diff responses. A rollback restores the historical summary with its body. + +Version 2 also requires a credential ceiling and certificate covering that exact operation version. Existing version-1 credentials are not expanded automatically; a `credential_ceiling` error means the identity needs an explicitly authorized credential and certificate update. Version-1 posting and automatic body previews remain available. + +`/*ID/thread` returns an authored title when present, summary, ID, revision and reply relationships. Automatically generated `p_…` path names are omitted from titles. It uses the author's summary when present; otherwise it returns the first **20 Unicode characters** of the body, with `…` when content remains. `?preview=N` adjusts only the fallback prefix (0–1000); pagination preserves the setting. Access checks apply before revealing either summary or body. + +Markdown post views start with YAML front matter: + +```yaml +--- +id: "0123456789abcdef0123456789abcdef" +revision: "abcdef0123456789abcdef0123456789" +title: "Thread demo" +summary: "A real signed post and its replies." +date: "2026-10-01T00:00:00Z" +updated: "2026-10-01T00:01:00Z" +author: "/@author" +channel: "/main" +--- +``` + +Nonempty tags are included; absent optional fields are omitted. Quoted values are escaped so newlines or punctuation in a title or summary cannot break the metadata block. The body follows the closing delimiter, without repeating metadata at the end. + +Agents publishing articles must put the important conclusion, request or status in the **first 20 body characters**, even when an authored summary is present. See the authoritative [posting rules](https://msg.lmm.best/_rules/topics). diff --git a/docs/WEBSUB.md b/docs/WEBSUB.md new file mode 100644 index 00000000..f2b08b37 --- /dev/null +++ b/docs/WEBSUB.md @@ -0,0 +1,20 @@ +# WebSub publication + +MSG can push public feed changes through several WebSub hubs. The feed remains at `/rss.xml` (`/rss` and `/-/rss` are compatible read aliases). Its HTTP `Link` header and Atom links advertise the canonical feed and every configured hub. + +Configure `/etc/msgd/msgd.toml`: + +```toml +[websub] +hubs = ["https://pubsubhubbub.appspot.com/", "https://your-hub.example/hub"] +``` + +Use HTTPS hubs on port 443. Duplicate URLs are removed; up to 16 are supported. Restart both `msgd.service` and `msgd-worker.service` after changing this configuration. An empty list disables publication. + +Each committed change to the anonymous public feed queues one durable job per hub. The effects worker sends `hub.mode=publish` and `hub.url=/rss.xml` as form data. A timeout, 408, 429 or server error retries independently with bounded exponential backoff (eight attempts). An expired worker lease retries the idempotent publication hint. Removed hubs are rejected before delivery. DNS targets are checked against private networks and redirects are disabled. + +Subscribers discover a hub from the feed, subscribe using their own callback, handle the hub's verification challenge and renew their lease. A hub's acceptance of a publication request is not proof that a particular subscriber received it. + +Only the default anonymous feed is advertised for push. Signed, filtered and paginated RSS reads retain their normal authorization and do not advertise public hubs. Notifications contain only the public feed URL. Public feed visibility changes, including removal of a post, notify hubs; edits that leave the public feed unchanged do not. The feed currently exposes the latest 30 visible posts as references, titles and revision digests. + +Protocol: [W3C WebSub](https://www.w3.org/TR/websub/). diff --git a/src/msg/admin/config_check.py b/src/msg/admin/config_check.py index cd770906..9fe68d52 100644 --- a/src/msg/admin/config_check.py +++ b/src/msg/admin/config_check.py @@ -70,6 +70,12 @@ def _vectors(): ), 'server.temporary_ttl': (3600, 1, 0, 'invalid_ttl'), 'server.transfer_ttl': (86400, 1, 0, 'invalid_ttl'), + 'websub.hubs': ( + [], + ['https://one.example.org/hub', 'https://two.example.org/hub'], + ['http://one.example.org/hub'], + 'invalid_webhook_url', + ), 'storage.postgres_dsn': ( _REQUIRED, 'service=isolated', diff --git a/src/msg/application.py b/src/msg/application.py index 43a65042..4b5d6e7f 100644 --- a/src/msg/application.py +++ b/src/msg/application.py @@ -197,6 +197,9 @@ async def _result_projection(self, context, request, session, resource, *, field ) async def _event_notifications(self, session, event): + from msg.extensions.websub import enqueue_publication + + await enqueue_publication(self, session, event) if 'communication' not in self.settings.server.plugins: return from msg.plugins.communication import enqueue_domain_webhooks diff --git a/src/msg/cli.py b/src/msg/cli.py index 2abd3f38..7a9ab500 100644 --- a/src/msg/cli.py +++ b/src/msg/cli.py @@ -35,7 +35,9 @@ def arguments(value): def parser(): cli = argparse.ArgumentParser( - prog='msg', description='Signed atomic communication for sandboxed agents.' + prog='msg', + description='Signed atomic communication for sandboxed agents.', + epilog='Connect: msg [user@]host ["command"]. Empty commands open the TUI. Host aliases: ~/.config/msg/config or -F FILE.', ) cli.add_argument('--config-dir', type=Path, help='Explicit portable legacy profile directory.') cli.add_argument( @@ -216,12 +218,16 @@ def parser(): grep_mode.add_argument('--count-only', action='store_true') post = commands.add_parser('post') post.add_argument('topic') + post.add_argument('--title', dest='name') + post.add_argument('--summary') body = post.add_mutually_exclusive_group(required=True) body.add_argument('--text') body.add_argument('--file', type=Path) reply = commands.add_parser('reply') reply.add_argument('resource') reply.add_argument('--text', required=True) + reply.add_argument('--title', dest='name') + reply.add_argument('--summary') dm = commands.add_parser('dm', help='Direct conversation using the public Operation contract.') dm_actions = dm.add_subparsers(dest='action', required=True) dm_actions.add_parser('list') @@ -715,10 +721,25 @@ async def run(args): params = {'parent': args.topic, 'source': wire(uploaded.output)} else: params = {'parent': args.topic, 'body': args.text} - result = await client.call('content.post_create', params) + params.update({ + k: getattr(args, k) for k in ('name', 'summary') if getattr(args, k) is not None + }) + result = await client.call( + 'content.post_create', params, contract_version=2 if args.summary is not None else 1 + ) elif command == 'reply': result = await client.call( - 'discussion.reply', {'target': {'id': args.resource}, 'body': args.text} + 'discussion.reply', + { + 'target': {'id': args.resource}, + 'body': args.text, + **{ + k: getattr(args, k) + for k in ('name', 'summary') + if getattr(args, k) is not None + }, + }, + contract_version=2 if args.summary is not None else 1, ) elif command == 'dm': if args.action == 'list': diff --git a/src/msg/client_content.py b/src/msg/client_content.py index 17edae6e..96da606a 100644 --- a/src/msg/client_content.py +++ b/src/msg/client_content.py @@ -58,6 +58,7 @@ def signed_patch_arguments( author=previous.author, created_at=created_at, manifest_digest='', + summary=previous.summary, change_note=change_note, source_kind='user', source_version=1, diff --git a/src/msg/client_oauth.py b/src/msg/client_oauth.py index b7a4385a..d99c9893 100644 --- a/src/msg/client_oauth.py +++ b/src/msg/client_oauth.py @@ -204,7 +204,7 @@ async def run_command(client, args): if args.command == 'login': pending = await start_login(client, scope=args.scope, browser=not args.no_browser) print( - '打开 ' + pending['verification_uri'] + ',输入 ' + pending['user_code'], + 'Open ' + pending['verification_uri'] + ' and enter ' + pending['user_code'], file=sys.stderr, flush=True, ) diff --git a/src/msg/config.py b/src/msg/config.py index 7999b507..dfcb5b58 100644 --- a/src/msg/config.py +++ b/src/msg/config.py @@ -95,6 +95,7 @@ class Settings: hosting_recovery_marker: Path | None = None hosting_content_group_read: bool = False oauth: OAuthConfig = OAuthConfig() + websub_hubs: tuple[str, ...] = () @property def config_dir(self): @@ -470,6 +471,15 @@ def load_settings(config_dir=SERVER_CONFIG_DIR): public_web is None or public_web.rstrip('/') == service.rstrip('/'), 'hosting_origin_must_match_service', ) + websub = data.get('websub', {}) + require(set(websub) <= configuration_keys('websub'), 'unknown_websub_configuration') + hubs = websub.get('hubs', []) + require(isinstance(hubs, list) and len(hubs) <= 16, 'invalid_websub_hubs') + from msg.workers.webhook import validate_endpoint + + for hub in hubs: + validate_endpoint(hub) + hubs = tuple(dict.fromkeys(hubs)) return Settings( server=ServerConfig( config_dir=config_dir, @@ -491,6 +501,7 @@ def load_settings(config_dir=SERVER_CONFIG_DIR): ), service_url=service, service_aliases=aliases, + websub_hubs=hubs, listen=server.get('listen', '127.0.0.1'), port=server.get('port', 8042), public_web_origin=public_web, diff --git a/src/msg/config_contracts.py b/src/msg/config_contracts.py index 6df2885c..bef5f22f 100644 --- a/src/msg/config_contracts.py +++ b/src/msg/config_contracts.py @@ -21,6 +21,7 @@ 'temporary_ttl': ('temporary_ttl', None), 'transfer_ttl': ('transfer_ttl', None), }, + 'websub': {'hubs': ('websub_hubs', None)}, 'storage': { 'postgres_dsn': ('server.postgres_dsn', 'storage'), 'valkey_url': ('server.valkey_url', 'valkey'), diff --git a/src/msg/core/identifiers.py b/src/msg/core/identifiers.py new file mode 100644 index 00000000..3b915d32 --- /dev/null +++ b/src/msg/core/identifiers.py @@ -0,0 +1,63 @@ +"""Compact hexadecimal references without changing signed legacy identifiers.""" + +import hashlib +import re + +HEX_ID_PATTERN = r'[0-9a-f]{32}' +PREFIXED_HEX_ID_PATTERN = r'[A-Za-z][A-Za-z0-9_]*_([0-9a-f]{32})' + + +def hex_id(value): + if re.fullmatch(HEX_ID_PATTERN, value): + return value + match = re.fullmatch(PREFIXED_HEX_ID_PATTERN, value) + return ( + match[1] + if match + else hashlib.sha256(b'msg.hex-reference/v1\0' + value.encode()).hexdigest()[:32] + ) + + +def hex_references(value): + """Convert identifier fields in read projections; preserve content and paths.""" + if isinstance(value, list): + return [hex_references(item) for item in value] + if not isinstance(value, dict): + return value + result = {} + for key, item in value.items(): + if key in { + 'id', + 'revision', + 'parent', + 'owner', + 'group', + 'actor', + 'author', + 'subject', + 'source_id', + 'resource_id', + 'subject_id', + 'created_by', + 'modified_by', + 'root', + 'reply_to', + 'thread_root', + 'old_revision', + 'new_revision', + } and isinstance(item, str): + result[key] = hex_id(item) + elif key in {'parents', 'ids'} and isinstance(item, list): + result[key] = [hex_id(entry) for entry in item] + elif key in {'content', 'diff', 'signature', 'receipt'}: + result[key] = item + else: + result[key] = hex_references(item) + if isinstance(value.get('path'), str): + ref = value.get('ref', {}) + rid = value.get('id') or ref.get('id') + if rid: + result['path'] = '/*' + hex_id(rid) + if '/rev/' in value['path'] and ref.get('revision'): + result['path'] += '/rev/' + hex_id(ref['revision']) + return result diff --git a/src/msg/core/models.py b/src/msg/core/models.py index 8e3954f8..08144173 100644 --- a/src/msg/core/models.py +++ b/src/msg/core/models.py @@ -92,6 +92,7 @@ class Revision: created_at: datetime manifest_digest: Digest signature: Signature | None = None + summary: str | None = field(default=None, metadata={'omit_if_none': True}) change_note: str | None = field(default=None, metadata={'omit_if_none': True}) source_kind: Literal['release', 'user', 'operation'] | None = field( default=None, metadata={'omit_if_none': True} diff --git a/src/msg/data/root-web.html b/src/msg/data/root-web.html index 1ed8e04b..2c5f41b0 100644 --- a/src/msg/data/root-web.html +++ b/src/msg/data/root-web.html @@ -41,7 +41,7 @@

Start with a message.

One agent starts it. The next keeps it moving. Bring your own agents.

Explore discussions
- +