@bongos/core 1.19.236 → 1.19.238
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +34 -19
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
- package/modules/discord/board-broadcast.js +212 -0
- package/modules/discord/db.js +57 -0
- package/modules/discord/migrations/discord_001_board_messages.sql +50 -0
- package/modules/discord/module.json +2 -1
- package/modules/discord/routes/discord.js +12 -0
- package/modules/discord/webhook.js +76 -11
- package/modules/government/board.js +58 -0
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/government.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/discord_board_broadcast.mjs +289 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.238",
|
|
6
|
+
"core_contract": "1.19.238",
|
|
7
|
+
"source_commit": "f9cb966629cf3f3b846fac291d0a8d3c80ff190c",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-18T18:46:41.252Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 311,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1323,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1655,
|
|
20
|
+
"tree_sha256": "0d58a08417103e42620b35bd5880c6abdd9770d6e0b0f2222c3f4161724649b8",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1502,7 +1502,7 @@
|
|
|
1502
1502
|
{
|
|
1503
1503
|
"path": "docs/file-map.md",
|
|
1504
1504
|
"mode": "0000644",
|
|
1505
|
-
"sha256": "
|
|
1505
|
+
"sha256": "0cb2b0b917f7d75a97d501c4c23e6a791aec4ed5c655025841d91ff8c1c10638"
|
|
1506
1506
|
},
|
|
1507
1507
|
{
|
|
1508
1508
|
"path": "docs/handoff-template.md",
|
|
@@ -2687,7 +2687,7 @@
|
|
|
2687
2687
|
{
|
|
2688
2688
|
"path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
|
|
2689
2689
|
"mode": "0000644",
|
|
2690
|
-
"sha256": "
|
|
2690
|
+
"sha256": "5c99db7b6b1b59e595c3c6a7002cf7baa3b130a198213b007448d02a427883f1"
|
|
2691
2691
|
},
|
|
2692
2692
|
{
|
|
2693
2693
|
"path": "modules/dev-box/app/test/unit.mjs",
|
|
@@ -2739,6 +2739,11 @@
|
|
|
2739
2739
|
"mode": "0000644",
|
|
2740
2740
|
"sha256": "a3261a569a381ed4b19f6f5d32598a9cec09f62be51533fd6b7c5272d53b63d3"
|
|
2741
2741
|
},
|
|
2742
|
+
{
|
|
2743
|
+
"path": "modules/discord/board-broadcast.js",
|
|
2744
|
+
"mode": "0000644",
|
|
2745
|
+
"sha256": "111b577698792565f55e66f031c6567ac548b4fc5194b51c262843b2bec66198"
|
|
2746
|
+
},
|
|
2742
2747
|
{
|
|
2743
2748
|
"path": "modules/discord/bug-attachments.js",
|
|
2744
2749
|
"mode": "0000644",
|
|
@@ -2747,7 +2752,7 @@
|
|
|
2747
2752
|
{
|
|
2748
2753
|
"path": "modules/discord/db.js",
|
|
2749
2754
|
"mode": "0000644",
|
|
2750
|
-
"sha256": "
|
|
2755
|
+
"sha256": "8c7f64ab027fa52885e047d47b68c3f6ed2cba79f55aaf8303266973a5370cf8"
|
|
2751
2756
|
},
|
|
2752
2757
|
{
|
|
2753
2758
|
"path": "modules/discord/discord-approvals.js",
|
|
@@ -2794,10 +2799,15 @@
|
|
|
2794
2799
|
"mode": "0000644",
|
|
2795
2800
|
"sha256": "52a7e8c1776621e37f0c19800b2e466d869d01750214bfd9f352a64d26ea91e9"
|
|
2796
2801
|
},
|
|
2802
|
+
{
|
|
2803
|
+
"path": "modules/discord/migrations/discord_001_board_messages.sql",
|
|
2804
|
+
"mode": "0000644",
|
|
2805
|
+
"sha256": "65e75ace1b53a55fd8d0dd6909a9c82c5b87c7eda5cf27662a3c8da73d010514"
|
|
2806
|
+
},
|
|
2797
2807
|
{
|
|
2798
2808
|
"path": "modules/discord/module.json",
|
|
2799
2809
|
"mode": "0000644",
|
|
2800
|
-
"sha256": "
|
|
2810
|
+
"sha256": "38d30f5a250d708cbbbee1c84a9a417d2a44d51dda1e6e4030f6b577d941491c"
|
|
2801
2811
|
},
|
|
2802
2812
|
{
|
|
2803
2813
|
"path": "modules/discord/pollers/discord-bot.js",
|
|
@@ -2812,7 +2822,7 @@
|
|
|
2812
2822
|
{
|
|
2813
2823
|
"path": "modules/discord/routes/discord.js",
|
|
2814
2824
|
"mode": "0000644",
|
|
2815
|
-
"sha256": "
|
|
2825
|
+
"sha256": "eeb7c7165f18e33216bff0bb8b846a1674f537a95e8853c0b88fc101c336fb53"
|
|
2816
2826
|
},
|
|
2817
2827
|
{
|
|
2818
2828
|
"path": "modules/discord/ship-broadcast.js",
|
|
@@ -2822,7 +2832,7 @@
|
|
|
2822
2832
|
{
|
|
2823
2833
|
"path": "modules/discord/webhook.js",
|
|
2824
2834
|
"mode": "0000644",
|
|
2825
|
-
"sha256": "
|
|
2835
|
+
"sha256": "59d2452516f4568c2a99c599275224ff0d2ff86b763066469a567944efbf5f69"
|
|
2826
2836
|
},
|
|
2827
2837
|
{
|
|
2828
2838
|
"path": "modules/economy/CLAUDE.md",
|
|
@@ -2902,7 +2912,7 @@
|
|
|
2902
2912
|
{
|
|
2903
2913
|
"path": "modules/government/board.js",
|
|
2904
2914
|
"mode": "0000644",
|
|
2905
|
-
"sha256": "
|
|
2915
|
+
"sha256": "5529a57a4aa92c3e1903764079a6a2b7d7eb3920c2f346e642a2158389f26b5d"
|
|
2906
2916
|
},
|
|
2907
2917
|
{
|
|
2908
2918
|
"path": "modules/government/catalog.js",
|
|
@@ -3272,12 +3282,12 @@
|
|
|
3272
3282
|
{
|
|
3273
3283
|
"path": "modules/hall-ui/public/government.html",
|
|
3274
3284
|
"mode": "0000644",
|
|
3275
|
-
"sha256": "
|
|
3285
|
+
"sha256": "7962d925606ec7c33e9579ebe8bdc45ef29b35899bacfb18d36af71a314629e2"
|
|
3276
3286
|
},
|
|
3277
3287
|
{
|
|
3278
3288
|
"path": "modules/hall-ui/public/government.js",
|
|
3279
3289
|
"mode": "0000644",
|
|
3280
|
-
"sha256": "
|
|
3290
|
+
"sha256": "b1de692afb7abb2bd95a91a31d7c8d244c9a9f702c8ab3d73214bb23472614ec"
|
|
3281
3291
|
},
|
|
3282
3292
|
{
|
|
3283
3293
|
"path": "modules/hall-ui/public/hall-kit.js",
|
|
@@ -4322,12 +4332,12 @@
|
|
|
4322
4332
|
{
|
|
4323
4333
|
"path": "package-lock.json",
|
|
4324
4334
|
"mode": "0000644",
|
|
4325
|
-
"sha256": "
|
|
4335
|
+
"sha256": "365922d9572fec6980fdb2806b48d83fc6e0892d63d63e909b733c748af6b51c"
|
|
4326
4336
|
},
|
|
4327
4337
|
{
|
|
4328
4338
|
"path": "package.json",
|
|
4329
4339
|
"mode": "0000644",
|
|
4330
|
-
"sha256": "
|
|
4340
|
+
"sha256": "d3c6422211a2678deb380518daf5639b83b527f9b1935e3a1f5427589aa1e9c1"
|
|
4331
4341
|
},
|
|
4332
4342
|
{
|
|
4333
4343
|
"path": "public-docs/index.html",
|
|
@@ -5942,7 +5952,7 @@
|
|
|
5942
5952
|
{
|
|
5943
5953
|
"path": "src/module-api.js",
|
|
5944
5954
|
"mode": "0000644",
|
|
5945
|
-
"sha256": "
|
|
5955
|
+
"sha256": "d303e5511fe7ea46cd1b77a24f27406b59d3a393bfc7bf32b133bff2f85b5b24"
|
|
5946
5956
|
},
|
|
5947
5957
|
{
|
|
5948
5958
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6619,6 +6629,11 @@
|
|
|
6619
6629
|
"mode": "0000644",
|
|
6620
6630
|
"sha256": "9765fcdc48191281115dfaac650719ac8f84257b602d1e83c351e9b9ad6d2949"
|
|
6621
6631
|
},
|
|
6632
|
+
{
|
|
6633
|
+
"path": "tests/discord_board_broadcast.mjs",
|
|
6634
|
+
"mode": "0000644",
|
|
6635
|
+
"sha256": "db1a02168869e633fccc62137f4db3e0f4efbf4a785db7cc0b49a30a8d4f418a"
|
|
6636
|
+
},
|
|
6622
6637
|
{
|
|
6623
6638
|
"path": "tests/discord_bot.mjs",
|
|
6624
6639
|
"mode": "0000644",
|
package/docs/file-map.md
CHANGED
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
├── modules/ ← self-contained optional feature modules (ADR 0083 / docs/modules-contract.md). Each `modules/<key>/` is a full vertical slice — routes, migrations, skills, seam registrations — reached only through src/module-api.js. The loader discovers them at boot; no core file changes to add one.
|
|
108
108
|
│ ├── dev-box/ ← per-builder cloud dev environments (DigitalOcean + Cloudflare tunnels, sandbox preview, branch publish). Template move (BV1.R43). CLAUDE.md, module.json, routes/box.js, boxes.js, box-access.js, box-onboard.js, do-api.js, cf-tunnel.js, http-api-client.js, box-credential.js.
|
|
109
109
|
│ ├── game/ ← the playable Example world — Phaser client, Colyseus rooms (WorldRoom/QueueRoom), world/terrain model, game DB tables, static assets. Moved BV1.R49 (task 1418). CLAUDE.md, module.json, routes/game.js, rooms/, world/, public/, migrations/.
|
|
110
|
-
│ ├── discord/ ← outbound ship broadcast + inbound #ideas/#bugs bot + role-sync + channel management + account-link state. Mirror only, never authority (ADR 0033). CLAUDE.md, module.json, routes/discord.js, routes/bug-attachments.js, discord-bot.js, discord-lib.js.
|
|
110
|
+
│ ├── discord/ ← outbound ship broadcast + inbound #ideas/#bugs bot + role-sync + channel management + account-link state + the Board Room mirror. Mirror only, never authority (ADR 0033). CLAUDE.md, module.json, routes/discord.js, routes/bug-attachments.js, discord-bot.js, discord-lib.js, board-broadcast.js (announce a window / edit the outcome onto it — R21/R22 of goal 1000069), migrations/discord_001_board_messages.sql.
|
|
111
111
|
│ ├── art-pipeline/ ← self-improving pixel-art generation pipeline + the four otb-* art skills + the artist discipline. CLAUDE.md, module.json, routes/art-key.js, art/ (lives under art/ at repo root; module owns the route and discipline entry).
|
|
112
112
|
│ ├── memory/ ← CORE-DOMAIN carve (BV1.R72, default-on): per-builder session memory + the recall/search retrieval layer (ADR 0060) + learnings. CLAUDE.md, module.json, routes/{memory,search,learnings}.js, memory.js, search.js, search-chunker.js, learnings.js.
|
|
113
113
|
│ ├── grading/ ← CORE-DOMAIN carve (BV1.R73, default-on): the grade-a-diff adversarial worker panel + deterministic pre-passes + rubric; registers the `grade` kernel port. CLAUDE.md, module.json, routes/grade.js, grader.js, grader-rubric.json.
|
|
@@ -348,8 +348,14 @@ function createClient(opts = {}) {
|
|
|
348
348
|
postGovernmentAssignments: (args) => request("POST", "/government/assignments", { hasBody: true }, args),
|
|
349
349
|
// DELETE /government/assignments/{builderId}/{rankKey} — rank: archon — DELETE /government/assignments/:builderId/:rankKey
|
|
350
350
|
deleteGovernmentAssignmentsBuilderIdRankKey: (args) => request("DELETE", "/government/assignments/{builderId}/{rankKey}", { hasBody: false }, args),
|
|
351
|
+
// GET /government/board/items — rank: any-builder — GET /government/board/items
|
|
352
|
+
getGovernmentBoardItems: (args) => request("GET", "/government/board/items", { hasBody: false }, args),
|
|
353
|
+
// POST /government/board/items — rank: metic+archon — POST /government/board/items
|
|
354
|
+
postGovernmentBoardItems: (args) => request("POST", "/government/board/items", { hasBody: true }, args),
|
|
351
355
|
// POST /government/board/items/{itemId}/votes — rank: any-builder — POST /government/board/items/:itemId/votes
|
|
352
356
|
postGovernmentBoardItemsItemIdVotes: (args) => request("POST", "/government/board/items/{itemId}/votes", { hasBody: true }, args),
|
|
357
|
+
// GET /government/constitution — rank: any-builder — GET /government/constitution
|
|
358
|
+
getGovernmentConstitution: (args) => request("GET", "/government/constitution", { hasBody: false }, args),
|
|
353
359
|
// GET /government/permissions — rank: archon — GET /government/permissions
|
|
354
360
|
getGovernmentPermissions: (args) => request("GET", "/government/permissions", { hasBody: false }, args),
|
|
355
361
|
// GET /government/ranks — rank: archon — GET /government/ranks
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// modules/discord/board-broadcast.js — the Board Room announcer
|
|
2
|
+
// (BV1.R21 of goal 1000069, ADR 0175 §8).
|
|
3
|
+
//
|
|
4
|
+
// On window open, the board channel hears that a vote is wanted: the members
|
|
5
|
+
// under the item's SNAPSHOTTED constitution are @mentioned (the mention IS the
|
|
6
|
+
// "vote within X" notice, and Discord's own mobile app IS the mobile push — no
|
|
7
|
+
// new surface), with a deep link straight to the Board Room in the hall.
|
|
8
|
+
//
|
|
9
|
+
// DISCORD IS OUTBOUND-ONLY HERE (§8, and the C6 criterion): this posts, it
|
|
10
|
+
// reads nothing inbound and casts nothing — an objection needs a reason and a
|
|
11
|
+
// section, which a reaction cannot express, so the hall is the only voting
|
|
12
|
+
// surface and this message is a doorway to it.
|
|
13
|
+
//
|
|
14
|
+
// THE MENTION EXCEPTION: webhook.postWebhookMentions allowlists exactly the
|
|
15
|
+
// member ids the GOVERNMENT PORT resolved — server data, never message text —
|
|
16
|
+
// under the item's snapshot, so an amendment mid-sitting cannot change who is
|
|
17
|
+
// summoned to an already-open item. A member without a Discord link is named
|
|
18
|
+
// in text instead (visible, unpinged, honest). #871's parse:[] still stands:
|
|
19
|
+
// nothing typed into content can ever ring anyone.
|
|
20
|
+
//
|
|
21
|
+
// THE MIRROR'S SECOND HALF (R22, task 1003035): when the item closes, the SAME
|
|
22
|
+
// message is edited to carry the outcome, so the channel never shows a stale open
|
|
23
|
+
// window and the room never has to guess which of two messages is current. The
|
|
24
|
+
// item's state lives in the DATABASE — this edit is a mirror of it, never a
|
|
25
|
+
// source, and nothing is ever cast from Discord.
|
|
26
|
+
//
|
|
27
|
+
// BEST-EFFORT BY DESIGN, like every broadcast in this module: it never throws,
|
|
28
|
+
// so a Discord hiccup can never fail a window open or a close — the room in the
|
|
29
|
+
// hall is the durable surface, this is the nudge.
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const api = require('../../src/module-api');
|
|
34
|
+
const db = require('./db');
|
|
35
|
+
const webhook = require('./webhook');
|
|
36
|
+
|
|
37
|
+
// Compose + post the announcement. deps injectable for the DB-free tests:
|
|
38
|
+
// resolveMembers(constitution) → the government port's resolveBoardMembers
|
|
39
|
+
// getLink(builderId) → db.getDiscordLink
|
|
40
|
+
// post(channel, text, userIds) → webhook.postWebhookMentions
|
|
41
|
+
// Strip markdown-significant characters from a builder-editable name so a
|
|
42
|
+
// crafted display name cannot carry formatting, links or lookalike mentions on
|
|
43
|
+
// the back of a legitimate announcement. The PING vector is already closed by
|
|
44
|
+
// allowed_mentions; this closes the cosmetic/phishing one.
|
|
45
|
+
function plainName(name) {
|
|
46
|
+
// '.' is stripped too: Discord auto-links bare domains, so a name shaped
|
|
47
|
+
// like one must not survive as a clickable.
|
|
48
|
+
return String(name).replace(/[\`*_~|@#>\[\]()<:.\/]/g, '').trim().slice(0, 64) || 'a member';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function postBoardAnnouncement(payload, deps = {}) {
|
|
52
|
+
const result = { posted: false, skipped: null, mentioned: [], named: [] };
|
|
53
|
+
if (!payload || payload.item_id == null) { result.skipped = 'no_item'; return result; }
|
|
54
|
+
const log = deps.log || console.log;
|
|
55
|
+
try {
|
|
56
|
+
const government = deps.government !== undefined ? deps.government : (api.resolveOptional && api.resolveOptional('government'));
|
|
57
|
+
const resolveMembers = deps.resolveMembers
|
|
58
|
+
|| (government && government.resolveBoardMembers ? (c) => government.resolveBoardMembers(c) : null);
|
|
59
|
+
if (!resolveMembers) { result.skipped = 'no_government_port'; return result; }
|
|
60
|
+
const members = await resolveMembers(payload.constitution || {});
|
|
61
|
+
if (!members || !members.length) { result.skipped = 'empty_board'; return result; }
|
|
62
|
+
|
|
63
|
+
// ONE bulk query for the whole board's links — never a per-member await.
|
|
64
|
+
const getLinks = deps.getLinks
|
|
65
|
+
|| (async (ids) => db.getDiscordLinks(ids));
|
|
66
|
+
let links = new Map();
|
|
67
|
+
try { links = await getLinks(members.map((m) => m.builder_id)); } catch (_) { /* unlinked reads as unlinked */ }
|
|
68
|
+
const mentions = [];
|
|
69
|
+
for (const m of members) {
|
|
70
|
+
const link = links.get(String(m.builder_id));
|
|
71
|
+
const discordId = link && (link.discord_user_id || link.discordUserId);
|
|
72
|
+
if (discordId) {
|
|
73
|
+
result.mentioned.push(String(discordId));
|
|
74
|
+
mentions.push(`<@${discordId}>`);
|
|
75
|
+
} else {
|
|
76
|
+
// Named, visible, unpinged — an unlinked member still sees their name
|
|
77
|
+
// when they read the room; linking is their call, not a gate. Names are
|
|
78
|
+
// builder-editable profile text, so markdown-significant characters are
|
|
79
|
+
// stripped: a crafted display name must not carry links or formatting
|
|
80
|
+
// on the back of a legitimate announcement.
|
|
81
|
+
const safe = plainName(m.display_name || m.github_login || `builder ${m.builder_id}`);
|
|
82
|
+
result.named.push(safe);
|
|
83
|
+
mentions.push(safe);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const subject = subjectLabel(payload.subject_type);
|
|
88
|
+
const clock = payload.window_minutes
|
|
89
|
+
? `within ${payload.window_minutes} minutes`
|
|
90
|
+
: 'at your convenience — the sitting has no clock and closes on the first vote';
|
|
91
|
+
const link = itemLink(payload.item_id);
|
|
92
|
+
const line = `\u{1F3DB}\u{FE0F} ${subject} is before the board (item ${payload.item_id}). `
|
|
93
|
+
+ `${mentions.join(' ')} — your vote is wanted ${clock}. Vote in the hall: ${link}`;
|
|
94
|
+
|
|
95
|
+
const post = deps.post || webhook.postWebhookMentions;
|
|
96
|
+
const sent = await post(CHANNEL, line, result.mentioned);
|
|
97
|
+
result.posted = true;
|
|
98
|
+
// Remember WHICH message this was, so R22's close can be edited onto it. A
|
|
99
|
+
// failed remember is not a failed announcement: the nudge is already in the
|
|
100
|
+
// channel, and the close then falls back to posting its outcome fresh.
|
|
101
|
+
const messageId = sent && (sent.message_id || (sent.message && sent.message.id));
|
|
102
|
+
if (messageId) {
|
|
103
|
+
const record = deps.recordMessage || ((row) => db.recordBoardMessage(row));
|
|
104
|
+
try {
|
|
105
|
+
await record({ itemId: payload.item_id, channel: CHANNEL, messageId: String(messageId) });
|
|
106
|
+
result.recorded = String(messageId);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
log(`[discord] board announcement posted but not recorded (item ${payload.item_id}; the close will post fresh): ${e && e.message}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
log(`[discord] board announcement posted (item ${payload.item_id}; ${result.mentioned.length} mentioned, ${result.named.length} named)`);
|
|
112
|
+
} catch (e) {
|
|
113
|
+
result.skipped = 'post_failed';
|
|
114
|
+
log(`[discord] board announcement skipped (window open unaffected): ${e && e.message}`);
|
|
115
|
+
}
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── shared composition ───────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
const CHANNEL = 'board_room';
|
|
122
|
+
|
|
123
|
+
function subjectLabel(subjectType) {
|
|
124
|
+
return subjectType === 'constitutional_amendment' ? 'A constitutional amendment' : 'A Full Idea';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Straight to THAT item: ?item=<id> — the Board Room panel reads it and scrolls
|
|
128
|
+
// to / highlights the named sitting (the hash stays the tab).
|
|
129
|
+
function itemLink(itemId) {
|
|
130
|
+
const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
|
|
131
|
+
return `${origin ? origin.replace(/\/$/, '') : ''}/government?item=${encodeURIComponent(itemId)}#board-room`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── the close (BV1.R22 of goal 1000069) ──────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
// PURE. The outcome message that replaces the summons, composed from the event
|
|
137
|
+
// payload alone — no Discord call, no DB read, so the wording is testable and the
|
|
138
|
+
// tally can never disagree with the row the government closed.
|
|
139
|
+
//
|
|
140
|
+
// WHAT IT DELIBERATELY DOES NOT SAY: nobody's name and nobody's vote. The open
|
|
141
|
+
// ballot is a HALL surface (ADR 0175 §9 — votes are visible to everyone there);
|
|
142
|
+
// mirroring who objected into a chat channel turns a reasoned objection into a
|
|
143
|
+
// public callout, and the reason plus its flagged section only make sense
|
|
144
|
+
// alongside the idea. The count is the honest summary; the link is the record.
|
|
145
|
+
function composeOutcomeLine(payload) {
|
|
146
|
+
const subject = subjectLabel(payload.subject_type);
|
|
147
|
+
const tally = payload.tally || {};
|
|
148
|
+
const yes = Number(tally.yes) || 0;
|
|
149
|
+
const no = Number(tally.no) || 0;
|
|
150
|
+
const verdict = payload.outcome === 'passed'
|
|
151
|
+
? 'RATIFIED by the board'
|
|
152
|
+
: 'RETURNED to its author';
|
|
153
|
+
// A zero-vote pass is a real, designed outcome (ADR 0175 §3 — an absence is
|
|
154
|
+
// nulled, never a block), so it says what happened rather than leaving the
|
|
155
|
+
// room to read "0 in favour" as a mistake.
|
|
156
|
+
const counted = (yes + no) === 0
|
|
157
|
+
? 'nobody objected before the window closed'
|
|
158
|
+
: `${yes} in favour, ${no} ${no === 1 ? 'objection' : 'objections'}`;
|
|
159
|
+
const how = payload.cause === 'expiry'
|
|
160
|
+
? 'the window ran out'
|
|
161
|
+
: 'the vote closed it';
|
|
162
|
+
const tail = payload.outcome === 'passed'
|
|
163
|
+
? 'The record:'
|
|
164
|
+
: 'The objection and the section it flags are in the hall:';
|
|
165
|
+
return `\u{1F3DB}\u{FE0F} ${subject} (item ${payload.item_id}) \u2014 ${verdict}. `
|
|
166
|
+
+ `${counted}; ${how}. ${tail} ${itemLink(payload.item_id)}`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Mirror the close onto the announcement. Result shape mirrors the announcer's:
|
|
170
|
+
// { edited, posted, skipped }
|
|
171
|
+
// edited — the summons itself now reads as the outcome (the happy path)
|
|
172
|
+
// posted — no message to edit, so the outcome went out fresh instead
|
|
173
|
+
// skipped — a NAMED reason; never a throw, so a Discord hiccup can never fail
|
|
174
|
+
// a close (the close already committed — this runs after it).
|
|
175
|
+
async function postBoardOutcome(payload, deps = {}) {
|
|
176
|
+
const result = { edited: false, posted: false, skipped: null };
|
|
177
|
+
if (!payload || payload.item_id == null) { result.skipped = 'no_item'; return result; }
|
|
178
|
+
if (payload.outcome !== 'passed' && payload.outcome !== 'returned') { result.skipped = 'no_outcome'; return result; }
|
|
179
|
+
const log = deps.log || console.log;
|
|
180
|
+
const line = composeOutcomeLine(payload);
|
|
181
|
+
try {
|
|
182
|
+
const getMessage = deps.getMessage || ((itemId) => db.getBoardMessage(itemId));
|
|
183
|
+
let row = null;
|
|
184
|
+
try { row = await getMessage(payload.item_id); } catch (_) { /* unreadable reads as unannounced */ }
|
|
185
|
+
if (row && row.message_id) {
|
|
186
|
+
const edit = deps.edit || webhook.editWebhook;
|
|
187
|
+
await edit(row.channel || CHANNEL, row.message_id, line);
|
|
188
|
+
result.edited = true;
|
|
189
|
+
const mark = deps.markEdited || ((itemId) => db.markBoardMessageEdited(itemId));
|
|
190
|
+
// The stamp is bookkeeping ONLY — `edited_at IS NULL` answers "does the
|
|
191
|
+
// channel still show this as open?" without asking Discord. A failed stamp
|
|
192
|
+
// must not turn a mirrored close into a reported failure.
|
|
193
|
+
try { await mark(payload.item_id); } catch (_) { /* the edit is what matters */ }
|
|
194
|
+
log(`[discord] board close mirrored onto the announcement (item ${payload.item_id}; ${payload.outcome})`);
|
|
195
|
+
return result;
|
|
196
|
+
}
|
|
197
|
+
// Nothing to edit: the window was never announced (Discord off at open time,
|
|
198
|
+
// an item older than R22), or the id could not be kept. Posting the outcome
|
|
199
|
+
// fresh keeps the room honest — and because this branch is reached only when
|
|
200
|
+
// there is NO stored message, a failed edit can never duplicate a notice.
|
|
201
|
+
const post = deps.post || webhook.postWebhook;
|
|
202
|
+
await post(CHANNEL, line);
|
|
203
|
+
result.posted = true;
|
|
204
|
+
log(`[discord] board close posted fresh — no announcement to edit (item ${payload.item_id}; ${payload.outcome})`);
|
|
205
|
+
} catch (e) {
|
|
206
|
+
result.skipped = 'mirror_failed';
|
|
207
|
+
log(`[discord] board close mirror skipped (the close itself is unaffected): ${e && e.message}`);
|
|
208
|
+
}
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
module.exports = { postBoardAnnouncement, postBoardOutcome, composeOutcomeLine };
|
package/modules/discord/db.js
CHANGED
|
@@ -46,6 +46,18 @@ async function listDiscordLinks() {
|
|
|
46
46
|
return rows;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
// Bulk twin of getDiscordLink (R21): one query for a whole board's links —
|
|
50
|
+
// the announcer mentions every member, and a per-member await is an N+1 the
|
|
51
|
+
// grader rightly flagged. Returns a Map keyed by builder_id AS A STRING.
|
|
52
|
+
async function getDiscordLinks(builderIds) {
|
|
53
|
+
if (!builderIds || !builderIds.length) return new Map();
|
|
54
|
+
const { rows } = await pool.query(
|
|
55
|
+
`SELECT builder_id, discord_user_id FROM discord_links WHERE builder_id = ANY($1::bigint[])`,
|
|
56
|
+
[builderIds],
|
|
57
|
+
);
|
|
58
|
+
return new Map(rows.map((r) => [String(r.builder_id), r]));
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
async function getDiscordLink(builderId) {
|
|
50
62
|
const { rows } = await pool.query(
|
|
51
63
|
`SELECT builder_id, discord_user_id, linked_at FROM discord_links WHERE builder_id = $1`,
|
|
@@ -67,6 +79,47 @@ async function countRecentDiscordBugFilings(builderId, sinceIso) {
|
|
|
67
79
|
return rows[0].n;
|
|
68
80
|
}
|
|
69
81
|
|
|
82
|
+
// --- the board mirror's own bookkeeping (R22 of goal 1000069) ----------------
|
|
83
|
+
//
|
|
84
|
+
// Which message announced which board item, so the close can be EDITED onto the
|
|
85
|
+
// same message instead of appended after it. Mirror state, discord-owned
|
|
86
|
+
// (discord_001_board_messages) — government's schema stays ignorant of whether a
|
|
87
|
+
// mirror exists.
|
|
88
|
+
|
|
89
|
+
// Upsert: R21 announces once, but a re-announced item (a re-fired hook, a manual
|
|
90
|
+
// re-drive) must leave ONE row pointing at the message that is actually live.
|
|
91
|
+
async function recordBoardMessage({ itemId, channel, messageId }) {
|
|
92
|
+
const { rows } = await pool.query(
|
|
93
|
+
`INSERT INTO discord_board_messages (item_id, channel, message_id)
|
|
94
|
+
VALUES ($1, $2, $3)
|
|
95
|
+
ON CONFLICT (item_id) DO UPDATE
|
|
96
|
+
SET channel = EXCLUDED.channel, message_id = EXCLUDED.message_id,
|
|
97
|
+
posted_at = now(), edited_at = NULL
|
|
98
|
+
RETURNING item_id, channel, message_id, posted_at, edited_at`,
|
|
99
|
+
[itemId, channel, String(messageId)],
|
|
100
|
+
);
|
|
101
|
+
return rows[0] || null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function getBoardMessage(itemId) {
|
|
105
|
+
const { rows } = await pool.query(
|
|
106
|
+
`SELECT item_id, channel, message_id, posted_at, edited_at
|
|
107
|
+
FROM discord_board_messages WHERE item_id = $1`,
|
|
108
|
+
[itemId],
|
|
109
|
+
);
|
|
110
|
+
return rows[0] || null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Stamped only after Discord accepted the edit, so `edited_at IS NULL` keeps
|
|
114
|
+
// meaning "the channel still shows this window as open".
|
|
115
|
+
async function markBoardMessageEdited(itemId) {
|
|
116
|
+
const { rowCount } = await pool.query(
|
|
117
|
+
`UPDATE discord_board_messages SET edited_at = now() WHERE item_id = $1`,
|
|
118
|
+
[itemId],
|
|
119
|
+
);
|
|
120
|
+
return { stamped: rowCount > 0 };
|
|
121
|
+
}
|
|
122
|
+
|
|
70
123
|
async function recordApplause({ targetTaskId, discordUserId }) {
|
|
71
124
|
const { rowCount } = await pool.query(
|
|
72
125
|
`INSERT INTO discord_applause (target_task_id, discord_user_id)
|
|
@@ -82,8 +135,12 @@ module.exports = {
|
|
|
82
135
|
getBuilderByDiscordUserId,
|
|
83
136
|
listDiscordLinks,
|
|
84
137
|
getDiscordLink,
|
|
138
|
+
getDiscordLinks,
|
|
85
139
|
countRecentDiscordBugFilings,
|
|
86
140
|
recordApplause,
|
|
141
|
+
recordBoardMessage,
|
|
142
|
+
getBoardMessage,
|
|
143
|
+
markBoardMessageEdited,
|
|
87
144
|
// kernel db helpers, re-exported off the doorway (one `db.*` namespace)
|
|
88
145
|
insertAuditLog: (...a) => api.insertAuditLog(...a),
|
|
89
146
|
// createTask is a LIFECYCLE write — resolve the `lifecycle` kernel port at call
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- discord_001_board_messages.sql — which message announced which board item
|
|
2
|
+
-- (task 1003035 / R22 of goal 1000069, ADR 0175 §8 / criterion C6).
|
|
3
|
+
--
|
|
4
|
+
-- R21 posts an announcement when a window opens; R22 EDITS THAT SAME MESSAGE when
|
|
5
|
+
-- the item closes, so the channel never shows a stale open window. Editing needs
|
|
6
|
+
-- the message id Discord handed back, and a window can stay open across restarts —
|
|
7
|
+
-- so the id has to be durable, and this is the smallest durable place for it.
|
|
8
|
+
--
|
|
9
|
+
-- WHY THIS TABLE LIVES IN THE DISCORD MODULE, NOT IN GOVERNMENT'S SCHEMA: a Discord
|
|
10
|
+
-- message id is mirror state, not government state (ADR 0033 — Discord is a one-way
|
|
11
|
+
-- window, never authority). government_board_items must stay ignorant of whether a
|
|
12
|
+
-- mirror exists; the mirror remembers its own bookkeeping. Module migrations are
|
|
13
|
+
-- ADDITIVE and NAMESPACED (ADR 0083 §Decision #5 / ADR 0108 §5): every object is
|
|
14
|
+
-- discord_-prefixed, the filename stem is unique across repos, and there is no
|
|
15
|
+
-- down-migration. Idempotent: safe to re-run.
|
|
16
|
+
--
|
|
17
|
+
-- NO FOREIGN KEY to government_board_items ON PURPOSE. A module migration may not
|
|
18
|
+
-- reach across the module boundary into a sibling's table (the same rule that keeps
|
|
19
|
+
-- anti-self-vote in the route rather than in a constraint — see
|
|
20
|
+
-- government_003_board_tables.sql), and the discord module is optional: an instance
|
|
21
|
+
-- that runs the board with Discord disabled must not carry a dangling reference.
|
|
22
|
+
-- A row here whose item is gone is harmless — the mirror simply has nothing to edit.
|
|
23
|
+
|
|
24
|
+
BEGIN;
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS discord_board_messages (
|
|
27
|
+
-- One announcement per board item: the mirror is edited in place, never appended
|
|
28
|
+
-- to, so PRIMARY KEY (not merely UNIQUE) is the honest shape and the upsert below
|
|
29
|
+
-- has a conflict target that can never admit a second row for the same sitting.
|
|
30
|
+
item_id bigint PRIMARY KEY,
|
|
31
|
+
|
|
32
|
+
-- The channel key (webhook.js CHANNELS), not a Discord snowflake: the edit must
|
|
33
|
+
-- go back through the SAME webhook that posted, and the webhook URL is resolved
|
|
34
|
+
-- from this key at edit time (config/env can be re-pointed between the post and
|
|
35
|
+
-- the close without stranding the row).
|
|
36
|
+
channel text NOT NULL,
|
|
37
|
+
|
|
38
|
+
-- Discord snowflakes exceed 2^53 and are strings in Discord's own API — text, so
|
|
39
|
+
-- no JSON round-trip can ever round one off.
|
|
40
|
+
message_id text NOT NULL,
|
|
41
|
+
|
|
42
|
+
posted_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
|
|
44
|
+
-- Set when the close is mirrored. NULL means "announced, not yet resolved" — a
|
|
45
|
+
-- read that answers "does this channel still show an open window?" without
|
|
46
|
+
-- asking Discord anything.
|
|
47
|
+
edited_at timestamptz
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
COMMIT;
|
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
"default": false,
|
|
7
7
|
"maintenance": { "status": "core-maintained" },
|
|
8
8
|
"contributes": {
|
|
9
|
+
"migrations": true,
|
|
9
10
|
"routes": ["discord", "bug-attachments"],
|
|
10
11
|
"pollers": ["discord-bot"],
|
|
11
12
|
"uiSections": []
|
|
12
13
|
},
|
|
13
14
|
"provides": ["discord.isLinked"],
|
|
14
|
-
"consumes": ["ideas.capture"],
|
|
15
|
+
"consumes": ["ideas.capture", "government"],
|
|
15
16
|
"dependencies": { "discord.js": "^14.26.4" }
|
|
16
17
|
}
|
|
@@ -17,6 +17,7 @@ const api = require('../../../src/module-api');
|
|
|
17
17
|
const auth = require('../auth');
|
|
18
18
|
const db = require('../db');
|
|
19
19
|
const shipBroadcast = require('../ship-broadcast');
|
|
20
|
+
const boardBroadcast = require('../board-broadcast');
|
|
20
21
|
const roleSync = require('../discord-role-sync');
|
|
21
22
|
|
|
22
23
|
// The module's kernel seams, registered when this factory runs (the loader runs
|
|
@@ -43,6 +44,8 @@ const roleSync = require('../discord-role-sync');
|
|
|
43
44
|
let _shipListenerOff = null;
|
|
44
45
|
let _strandListenerOff = null;
|
|
45
46
|
let _roleSyncListenerOff = null;
|
|
47
|
+
let _boardListenerOff = null;
|
|
48
|
+
let _boardCloseListenerOff = null;
|
|
46
49
|
function registerSeams() {
|
|
47
50
|
if (!api.hasProvider('discord.isLinked')) {
|
|
48
51
|
api.registerProvider('discord.isLinked', async (builderId) => !!(await db.getDiscordLink(builderId)));
|
|
@@ -58,6 +61,15 @@ function registerSeams() {
|
|
|
58
61
|
_shipListenerOff = api.on('task.shipped', (task) => shipBroadcast.postShipBroadcast(task));
|
|
59
62
|
if (_strandListenerOff) _strandListenerOff();
|
|
60
63
|
_strandListenerOff = api.on('task.strand-flagged', (info) => shipBroadcast.postStrandNotice(info));
|
|
64
|
+
// R21 (goal 1000069): a window opening in the Board Room summons its members
|
|
65
|
+
// — the government module emits, this module posts. Outbound only (§8).
|
|
66
|
+
if (_boardListenerOff) _boardListenerOff();
|
|
67
|
+
_boardListenerOff = api.on('board.item.opened', (payload) => boardBroadcast.postBoardAnnouncement(payload));
|
|
68
|
+
// R22: the same sitting, closed — the announcement is EDITED into an outcome
|
|
69
|
+
// notice, so the channel never shows a stale open window. Fires on a return as
|
|
70
|
+
// well as a pass; still outbound only, still nothing cast from Discord.
|
|
71
|
+
if (_boardCloseListenerOff) _boardCloseListenerOff();
|
|
72
|
+
_boardCloseListenerOff = api.on('board.item.closed', (payload) => boardBroadcast.postBoardOutcome(payload));
|
|
61
73
|
if (_roleSyncListenerOff) _roleSyncListenerOff();
|
|
62
74
|
_roleSyncListenerOff = api.on('builder.role-sync-needed', ({ builderId }) =>
|
|
63
75
|
roleSync.syncBuilderRole(builderId).catch((e) =>
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// module needs lives IN the module. scripts/discord/lib.js stays for the two CORE
|
|
8
8
|
// consumers that still post webhooks (src/bongos/approval-broadcast.js + the
|
|
9
9
|
// scripts/discord/post.js CLI). KEEP THE TWO IN SYNC — same channels, same
|
|
10
|
-
// 2000-char cap, same allowed_mentions suppression (#871)
|
|
10
|
+
// 2000-char cap, same allowed_mentions suppression (#871) — the R22 board-mirror
|
|
11
|
+
// edit path (postCore's `wait=true` + editWebhook) is deliberately module-only:
|
|
12
|
+
// no core consumer edits a message it posted, so copying it into the twin would
|
|
13
|
+
// add an unused surface rather than keep the two honest. Config is read through
|
|
11
14
|
// the published doorway (configPath) so the path resolves identically.
|
|
12
15
|
//
|
|
13
16
|
// Discord is a one-way window into the GDS (the trust-boundary ADR): this only
|
|
@@ -27,6 +30,7 @@ const WEBHOOKS_PATH = api.configPath('discord-webhooks.json');
|
|
|
27
30
|
const CHANNELS = new Set([
|
|
28
31
|
'ship_news', 'ship_feed', 'security_ship_feed',
|
|
29
32
|
'member_approvals',
|
|
33
|
+
'board_room',
|
|
30
34
|
]);
|
|
31
35
|
|
|
32
36
|
function loadWebhooks() {
|
|
@@ -43,7 +47,11 @@ function loadWebhooks() {
|
|
|
43
47
|
return cfg;
|
|
44
48
|
}
|
|
45
49
|
|
|
46
|
-
|
|
50
|
+
// One poster core; the two entry points differ ONLY in allowed_mentions.
|
|
51
|
+
// #871's rule holds in both: parse:[] always, so nothing typed INTO content can
|
|
52
|
+
// ever ring — postWebhookMentions adds an explicit server-resolved user
|
|
53
|
+
// allowlist and nothing else.
|
|
54
|
+
function resolveWebhookUrl(channel) {
|
|
47
55
|
if (!CHANNELS.has(channel)) {
|
|
48
56
|
throw new Error(`Unknown Discord channel '${channel}'. Known: ${[...CHANNELS].join(', ')}`);
|
|
49
57
|
}
|
|
@@ -54,21 +62,78 @@ async function postWebhook(channel, text) {
|
|
|
54
62
|
`or env DISCORD_${channel.toUpperCase()}_WEBHOOK. See scripts/discord/README.md.`
|
|
55
63
|
);
|
|
56
64
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Discord hard-caps message content at 2000 chars — one place, so a post and an
|
|
69
|
+
// edit of the same text can never disagree about what got truncated.
|
|
70
|
+
function capContent(text) {
|
|
71
|
+
return String(text).slice(0, 2000);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// R22 (the board mirror) needs the id of the message it just posted so the close
|
|
75
|
+
// can be edited onto it, so the POST asks for the created message back
|
|
76
|
+
// (`?wait=true` — Discord then answers 200 + the message JSON instead of 204 with
|
|
77
|
+
// no body). The extra field is additive: every existing caller ignores the return.
|
|
78
|
+
async function postCore(channel, text, allowedMentions) {
|
|
79
|
+
const url = resolveWebhookUrl(channel);
|
|
80
|
+
const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}wait=true`, {
|
|
63
81
|
method: 'POST',
|
|
64
82
|
headers: { 'Content-Type': 'application/json' },
|
|
65
|
-
body: JSON.stringify({ content, allowed_mentions:
|
|
83
|
+
body: JSON.stringify({ content: capContent(text), allowed_mentions: allowedMentions }),
|
|
66
84
|
});
|
|
67
85
|
if (!res.ok) {
|
|
68
86
|
const body = await res.text().catch(() => '');
|
|
69
87
|
throw new Error(`Discord webhook POST -> ${res.status}: ${body.slice(0, 300)}`);
|
|
70
88
|
}
|
|
71
|
-
|
|
89
|
+
// `wait=true` answers with the message; a body we cannot parse is not a failed
|
|
90
|
+
// post — the message IS in the channel, we just cannot edit it later.
|
|
91
|
+
const message = await res.json().catch(() => null);
|
|
92
|
+
return { ok: true, status: res.status, message, message_id: message && message.id ? String(message.id) : null };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// SECURITY (#871): suppress ALL mention pings. The content is DB-sourced and
|
|
96
|
+
// attacker-influenceable; allowed_mentions:{parse:[]} renders text verbatim but
|
|
97
|
+
// fires zero notifications. (Discord defaults to parsing mentions when omitted.)
|
|
98
|
+
async function postWebhook(channel, text) {
|
|
99
|
+
return postCore(channel, text, { parse: [] });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// postWebhookMentions — the ONE sanctioned exception to #871's blanket mention
|
|
103
|
+
// suppression (R21 of goal 1000069: "the mention IS the 'vote within X'
|
|
104
|
+
// notice"). Still parse:[] — nothing typed INTO the content can ever ping — but
|
|
105
|
+
// an explicit `users` allowlist lets exactly the named ids ring. The ids must
|
|
106
|
+
// come from the board-membership resolve (server data), NEVER from message
|
|
107
|
+
// text; the cap of 20 is a sanity bound far above any real board.
|
|
108
|
+
async function postWebhookMentions(channel, text, userIds = []) {
|
|
109
|
+
const users = [...new Set(userIds.filter((u) => /^\d+$/.test(String(u))))].slice(0, 20).map(String);
|
|
110
|
+
return postCore(channel, text, { parse: [], users });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// editWebhook — rewrite a message this webhook already posted, in place (R22 of
|
|
114
|
+
// goal 1000069). PATCH /webhooks/<id>/<token>/messages/<message_id>, which is the
|
|
115
|
+
// only edit a webhook is allowed: its OWN messages, in the channel it owns.
|
|
116
|
+
//
|
|
117
|
+
// FULLY SUPPRESSED, ALWAYS. The edit is an outcome notice, not a summons — and an
|
|
118
|
+
// edit that carried a mentions allowlist could re-ring a board that already voted.
|
|
119
|
+
// parse:[] with no `users` is #871's default, and the mention exception stays
|
|
120
|
+
// scoped to the one post that means "your vote is wanted".
|
|
121
|
+
async function editWebhook(channel, messageId, text) {
|
|
122
|
+
const url = resolveWebhookUrl(channel);
|
|
123
|
+
const [base] = String(url).split('?');
|
|
124
|
+
const res = await fetch(`${base.replace(/\/$/, '')}/messages/${encodeURIComponent(String(messageId))}`, {
|
|
125
|
+
method: 'PATCH',
|
|
126
|
+
headers: { 'Content-Type': 'application/json' },
|
|
127
|
+
body: JSON.stringify({ content: capContent(text), allowed_mentions: { parse: [] } }),
|
|
128
|
+
});
|
|
129
|
+
if (!res.ok) {
|
|
130
|
+
const body = await res.text().catch(() => '');
|
|
131
|
+
throw new Error(`Discord webhook PATCH -> ${res.status}: ${body.slice(0, 300)}`);
|
|
132
|
+
}
|
|
133
|
+
return { ok: true, status: res.status };
|
|
72
134
|
}
|
|
73
135
|
|
|
74
|
-
module.exports = {
|
|
136
|
+
module.exports = {
|
|
137
|
+
CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl,
|
|
138
|
+
postWebhook, postWebhookMentions, editWebhook,
|
|
139
|
+
};
|
|
@@ -81,9 +81,56 @@ async function openWindowForFullIdea({ ideaId, completenessScore } = {}) {
|
|
|
81
81
|
windowMinutes: board.window_minutes,
|
|
82
82
|
});
|
|
83
83
|
if (!item) return { opened: false, reason: 'already_open', item: await db.getOpenBoardItem('full_idea', id) };
|
|
84
|
+
await emitWindowOpened(item);
|
|
84
85
|
return { opened: true, item };
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
// The window-open courtesy event (R21's feed): emitAsync AFTER the row exists,
|
|
89
|
+
// for non-deciding reactions only — the Discord announcer mentions the members
|
|
90
|
+
// whose vote is wanted and deep-links the hall. Best-effort by construction
|
|
91
|
+
// (emitAsync isolates listener errors), and nothing that decides or pays may
|
|
92
|
+
// subscribe: the row is the durable fact, the event is a nudge.
|
|
93
|
+
async function emitWindowOpened(item) {
|
|
94
|
+
try {
|
|
95
|
+
if (api.emitAsync) {
|
|
96
|
+
await api.emitAsync('board.item.opened', {
|
|
97
|
+
item_id: String(item.id),
|
|
98
|
+
subject_type: item.subject_type,
|
|
99
|
+
subject_id: String(item.subject_id),
|
|
100
|
+
opened_by: item.opened_by == null ? null : String(item.opened_by),
|
|
101
|
+
closes_at: item.closes_at || null,
|
|
102
|
+
window_minutes: item.constitution ? item.constitution.window_minutes ?? null : null,
|
|
103
|
+
constitution: item.constitution || null,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
} catch (_) { /* a nudge that fails is a quiet sitting, never a failed open */ }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// The close event (R22's Discord mirror): the summons that R21 posted is edited
|
|
110
|
+
// into an outcome notice, so the channel never shows a stale open window. Emitted
|
|
111
|
+
// for a RETURN as well as a pass, with the tally the close already counted — no
|
|
112
|
+
// consumer should have to re-read votes to state what happened. Best-effort by
|
|
113
|
+
// construction, like the open nudge.
|
|
114
|
+
async function emitItemClosed(closed, { cause, votes } = {}) {
|
|
115
|
+
try {
|
|
116
|
+
if (!api.emitAsync) return;
|
|
117
|
+
const cast = votes || [];
|
|
118
|
+
await api.emitAsync('board.item.closed', {
|
|
119
|
+
item_id: String(closed.id),
|
|
120
|
+
subject_type: closed.subject_type,
|
|
121
|
+
subject_id: String(closed.subject_id),
|
|
122
|
+
outcome: closed.outcome,
|
|
123
|
+
cause: cause || 'vote',
|
|
124
|
+
closed_at: closed.closed_at || null,
|
|
125
|
+
tally: {
|
|
126
|
+
yes: cast.filter((v) => v.direction === 'yes').length,
|
|
127
|
+
no: cast.filter((v) => v.direction === 'no').length,
|
|
128
|
+
total: cast.length,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
} catch (_) { /* a mirror that fails is a quiet channel, never a failed close */ }
|
|
132
|
+
}
|
|
133
|
+
|
|
87
134
|
// ── the vote (BV1.R08 of goal 1000069, ADR 0175 §§4/6/9) ────────────────────
|
|
88
135
|
//
|
|
89
136
|
// OPEN BALLOT, ONE VOTE, NO EDITING. A vote is member + direction + timestamp,
|
|
@@ -366,6 +413,16 @@ async function closeItem(item, { cause = 'vote' } = {}) {
|
|
|
366
413
|
} catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
|
|
367
414
|
}
|
|
368
415
|
|
|
416
|
+
// R22's mirror event: BOTH outcomes, after commit, emitAsync. Separate from
|
|
417
|
+
// `board.item.passed` on purpose — that one is the pass CONTRACT the credit lane
|
|
418
|
+
// reads (docs/specs/board-room-pass-event-contract.md: seven fields, no tally),
|
|
419
|
+
// and a mirror needs the opposite things: it fires on a return too, and it
|
|
420
|
+
// carries the count so the channel can state the outcome without reading the DB.
|
|
421
|
+
// Widening the contract event to cover returns would put a payer on a path that
|
|
422
|
+
// also fires for "nothing was ratified". Nothing that decides or pays may
|
|
423
|
+
// subscribe here — the row is the durable fact, this is a nudge.
|
|
424
|
+
await emitItemClosed(closed, { cause, votes });
|
|
425
|
+
|
|
369
426
|
return {
|
|
370
427
|
closed: true, item: closed, outcome, cause, author_karma: authorKarma,
|
|
371
428
|
...(credit ? { credit } : {}),
|
|
@@ -450,6 +507,7 @@ async function proposeAmendment({ proposal, rationaleMd = null, proposedBy } = {
|
|
|
450
507
|
windowMinutes: board.window_minutes,
|
|
451
508
|
});
|
|
452
509
|
// A fresh amendment id can never collide with the one-open-per-subject index.
|
|
510
|
+
if (item) await emitWindowOpened(item);
|
|
453
511
|
return { ok: true, amendment, item };
|
|
454
512
|
}
|
|
455
513
|
|
|
@@ -99,6 +99,6 @@
|
|
|
99
99
|
<script src="/builders/palette.js" defer></script>
|
|
100
100
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
101
101
|
<script src="/builders/hall-kit.js"></script>
|
|
102
|
-
<script src="/builders/government.js?v=2026-08-18-
|
|
102
|
+
<script src="/builders/government.js?v=2026-08-18-board-announce"></script>
|
|
103
103
|
</body>
|
|
104
104
|
</html>
|
|
@@ -704,7 +704,7 @@
|
|
|
704
704
|
const myLine = item.closed_at ? '' : (mine
|
|
705
705
|
? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
|
|
706
706
|
: `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
|
|
707
|
-
return `<section class="gov-section">
|
|
707
|
+
return `<section class="gov-section" id="board-item-${escapeHtml(item.id)}">
|
|
708
708
|
<div class="gov-section__head">${subjectLine(item)} ${state}</div>
|
|
709
709
|
${amendmentDetail}
|
|
710
710
|
<p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
|
|
@@ -732,6 +732,17 @@
|
|
|
732
732
|
(open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
|
|
733
733
|
+ `<h2 class="gov-h">Closed sittings</h2>`
|
|
734
734
|
+ (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
|
|
735
|
+
// R21: the Discord announcement deep-links ?item=<id>#board-room — land the
|
|
736
|
+
// reader on THAT sitting. Query, not hash (the hash is the tab, makeTabs
|
|
737
|
+
// owns it); a missing or unknown id is simply the room as usual.
|
|
738
|
+
const wanted = new URLSearchParams(window.location.search).get('item');
|
|
739
|
+
if (wanted) {
|
|
740
|
+
const el = document.getElementById(`board-item-${wanted}`);
|
|
741
|
+
if (el) {
|
|
742
|
+
el.scrollIntoView({ block: 'start' });
|
|
743
|
+
el.style.outline = '2px solid var(--color-text-success, #4a4)';
|
|
744
|
+
}
|
|
745
|
+
}
|
|
735
746
|
// R17: the cast buttons. One delegated listener; the SERVER refuses every
|
|
736
747
|
// illegitimate cast regardless (membership, anti-self, closed, expired) —
|
|
737
748
|
// the form is a courtesy, the route is the wall.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.238",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.238",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.238",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -899,8 +899,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
899
899
|
// landed since 1.19.234 with no explicit bump. run 32161981393. (task 1002620)
|
|
900
900
|
// 1.19.236 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
901
901
|
// landed since 1.19.235 with no explicit bump. run 32162811851. (task 1002620)
|
|
902
|
+
// 1.19.237 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
903
|
+
// landed since 1.19.236 with no explicit bump. run 32164526454. (task 1002620)
|
|
904
|
+
// 1.19.238 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
905
|
+
// landed since 1.19.237 with no explicit bump. run 32172843539. (task 1002620)
|
|
902
906
|
// ---------------------------------------------------------------------------
|
|
903
|
-
const CORE_VERSION = '1.19.
|
|
907
|
+
const CORE_VERSION = '1.19.238'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
904
908
|
|
|
905
909
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
906
910
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
// tests/discord_board_broadcast.mjs — announce a window (BV1.R21 of goal
|
|
2
|
+
// 1000069, ADR 0175 §8 / criterion C6): mention the members, deep-link the hall.
|
|
3
|
+
//
|
|
4
|
+
// DB-free, deps-injected (the postShipBroadcast testing shape). The invariants:
|
|
5
|
+
// 1. THE MENTION ALLOWLIST IS EXACTLY THE LINKED MEMBERS — ids come from the
|
|
6
|
+
// government port's resolve under the item's SNAPSHOT, never from text;
|
|
7
|
+
// an unlinked member is NAMED, visible and unpinged.
|
|
8
|
+
// 2. OUTBOUND-ONLY AND BEST-EFFORT — a Discord hiccup, a missing port, or an
|
|
9
|
+
// empty board is a named skip, never a throw (a failed nudge must never
|
|
10
|
+
// fail a window open).
|
|
11
|
+
// 3. THE EMITTER FIRES ON FRESH OPENS ONLY — an idempotent re-open (the
|
|
12
|
+
// already_open path) summons nobody twice.
|
|
13
|
+
// 4. #871 SURVIVES — the poster keeps parse:[] and only rings the explicit
|
|
14
|
+
// user allowlist; content can never ping anyone on its own.
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import { createRequire } from 'node:module';
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
24
|
+
const { postBoardAnnouncement } = require(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'));
|
|
25
|
+
const webhook = require(path.join(ROOT, 'modules', 'discord', 'webhook.js'));
|
|
26
|
+
|
|
27
|
+
const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
28
|
+
const payload = (over = {}) => ({
|
|
29
|
+
item_id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
|
|
30
|
+
closes_at: null, window_minutes: null, constitution: MONARCHY, ...over,
|
|
31
|
+
});
|
|
32
|
+
const member = (id, login) => ({ builder_id: String(id), github_login: login, display_name: login });
|
|
33
|
+
|
|
34
|
+
test('linked members are @mentioned on the allowlist; unlinked members are named, unpinged', async () => {
|
|
35
|
+
let posted = null;
|
|
36
|
+
const out = await postBoardAnnouncement(payload({ window_minutes: 90 }), {
|
|
37
|
+
resolveMembers: async (c) => { assert.deepEqual(c, MONARCHY, 'summoned under the item SNAPSHOT'); return [member(1, 'linked-one'), member(2, 'unlinked-two')]; },
|
|
38
|
+
getLinks: async (ids) => {
|
|
39
|
+
assert.deepEqual(ids, ['1', '2'], 'ONE bulk query for the whole board — never a per-member await');
|
|
40
|
+
return new Map([['1', { discord_user_id: '111222333' }]]);
|
|
41
|
+
},
|
|
42
|
+
post: async (channel, text, userIds) => { posted = { channel, text, userIds }; },
|
|
43
|
+
log: () => {},
|
|
44
|
+
});
|
|
45
|
+
assert.equal(out.posted, true);
|
|
46
|
+
assert.deepEqual(out.mentioned, ['111222333']);
|
|
47
|
+
assert.deepEqual(out.named, ['unlinked-two']);
|
|
48
|
+
assert.equal(posted.channel, 'board_room');
|
|
49
|
+
assert.deepEqual(posted.userIds, ['111222333'], 'the allowlist is EXACTLY the linked members — never parsed from text');
|
|
50
|
+
assert.ok(posted.text.includes('<@111222333>'));
|
|
51
|
+
assert.ok(posted.text.includes('unlinked-two'), 'the unlinked member is still named');
|
|
52
|
+
assert.ok(posted.text.includes('within 90 minutes'), 'the mention IS the vote-within-X notice');
|
|
53
|
+
assert.ok(posted.text.includes('/government?item=11#board-room'), 'deep-links THAT item — the panel scrolls to it');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('a clockless sitting says so, and an amendment names its subject honestly', async () => {
|
|
57
|
+
let text = null;
|
|
58
|
+
await postBoardAnnouncement(payload({ subject_type: 'constitutional_amendment', subject_id: '5' }), {
|
|
59
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
60
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
61
|
+
post: async (_c, t) => { text = t; },
|
|
62
|
+
log: () => {},
|
|
63
|
+
});
|
|
64
|
+
assert.ok(text.includes('constitutional amendment'));
|
|
65
|
+
assert.ok(text.includes('no clock'), 'a monarchy sitting has no deadline to claim');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('missing port, empty board, and a Discord failure are named skips — NEVER throws', async () => {
|
|
69
|
+
assert.deepEqual(await postBoardAnnouncement(payload(), { government: null, log: () => {} }),
|
|
70
|
+
{ posted: false, skipped: 'no_government_port', mentioned: [], named: [] });
|
|
71
|
+
assert.equal((await postBoardAnnouncement(payload(), { resolveMembers: async () => [], log: () => {} })).skipped, 'empty_board');
|
|
72
|
+
const out = await postBoardAnnouncement(payload(), {
|
|
73
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
74
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
75
|
+
post: async () => { throw new Error('discord down'); },
|
|
76
|
+
log: () => {},
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual({ posted: out.posted, skipped: out.skipped }, { posted: false, skipped: 'post_failed' });
|
|
79
|
+
assert.equal((await postBoardAnnouncement(null, { log: () => {} })).skipped, 'no_item');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('the emitter fires on FRESH opens only, and the listener is registered (source pins)', () => {
|
|
83
|
+
const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
84
|
+
const open = board.slice(board.indexOf('async function openWindowForFullIdea'), board.indexOf('async function emitWindowOpened'));
|
|
85
|
+
assert.ok(open.indexOf('already_open') < open.indexOf('emitWindowOpened(item)'),
|
|
86
|
+
'the idempotent re-open path returns BEFORE the emit — nobody is summoned twice');
|
|
87
|
+
assert.match(board, /emitAsync\('board\.item\.opened'/, 'the event exists');
|
|
88
|
+
const routes = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'routes', 'discord.js'), 'utf8');
|
|
89
|
+
assert.match(routes, /api\.on\('board\.item\.opened', \(payload\) => boardBroadcast\.postBoardAnnouncement\(payload\)\)/,
|
|
90
|
+
'the discord module subscribes — government emits, discord posts, nothing decides');
|
|
91
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'module.json'), 'utf8'));
|
|
92
|
+
assert.ok(manifest.consumes.includes('government'), 'the port consumption is declared');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('#871 survives: the mention poster keeps parse:[] and rings only the explicit allowlist', () => {
|
|
96
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
|
|
97
|
+
const block = src.slice(src.indexOf('async function postWebhookMentions'));
|
|
98
|
+
assert.match(block, /postCore\(channel, text, \{ parse: \[\], users \}\)/,
|
|
99
|
+
'content can never ping on its own — only the server-resolved ids ring');
|
|
100
|
+
const plain = src.slice(src.indexOf('async function postWebhook('), src.indexOf('async function postWebhookMentions'));
|
|
101
|
+
assert.match(plain, /postCore\(channel, text, \{ parse: \[\] \}\)/,
|
|
102
|
+
'the default poster stays fully suppressed — #871 verbatim');
|
|
103
|
+
assert.match(block, /slice\(0, 20\)/, 'the allowlist is sanity-bounded');
|
|
104
|
+
assert.match(block, /\^\\d\+\$/, 'only numeric snowflake ids survive the filter');
|
|
105
|
+
assert.ok(webhook.CHANNELS.has('board_room'), 'the channel key exists');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('an unlinked member\'s crafted display name is stripped of markdown and links', async () => {
|
|
109
|
+
let text = null;
|
|
110
|
+
await postBoardAnnouncement(payload(), {
|
|
111
|
+
resolveMembers: async () => [{ builder_id: '3', github_login: 'x', display_name: '[click me](https://evil.example) **@everyone**' }],
|
|
112
|
+
getLinks: async () => new Map(),
|
|
113
|
+
post: async (_c, t) => { text = t; },
|
|
114
|
+
log: () => {},
|
|
115
|
+
});
|
|
116
|
+
assert.ok(!text.includes('['), 'no markdown link syntax survives');
|
|
117
|
+
assert.ok(!/\w\.\w/.test(text.replace(/Vote in the hall:.*$/,'')), 'no linkable residue survives (Discord auto-links bare domains)');
|
|
118
|
+
assert.ok(!text.includes('**'), 'no formatting survives');
|
|
119
|
+
assert.ok(!text.includes('@everyone'), 'no lookalike mention survives (the ping was already dead; now the text is too)');
|
|
120
|
+
assert.ok(text.includes('click me'), 'the words themselves remain — visible, honest, inert');
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── R22 (task 1003035): the state mirror — closed, and the outcome ───────────
|
|
124
|
+
//
|
|
125
|
+
// The invariants R22 adds:
|
|
126
|
+
// 6. THE SAME MESSAGE CARRIES THE OUTCOME — the summons is EDITED, never
|
|
127
|
+
// appended to, so the channel can never show a stale open window beside a
|
|
128
|
+
// closed one. The id comes from the POST (`wait=true`) and is remembered.
|
|
129
|
+
// 7. BOTH OUTCOMES MIRROR — a return is mirrored exactly like a pass; only the
|
|
130
|
+
// pass CONTRACT event stays pass-only (the credit lane's payload).
|
|
131
|
+
// 8. STILL BEST-EFFORT, STILL OUTBOUND-ONLY — every failure is a named skip,
|
|
132
|
+
// an unannounced item posts fresh rather than going silent, and an edit
|
|
133
|
+
// never re-rings the board.
|
|
134
|
+
const { postBoardOutcome, composeOutcomeLine } = require(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'));
|
|
135
|
+
const closedPayload = (over = {}) => ({
|
|
136
|
+
item_id: '11', subject_type: 'full_idea', subject_id: '7',
|
|
137
|
+
outcome: 'passed', cause: 'vote', closed_at: '2026-08-18T12:00:00.000Z',
|
|
138
|
+
tally: { yes: 1, no: 0, total: 1 }, ...over,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the announcement is remembered at post time, so the close has something to edit', async () => {
|
|
142
|
+
let recorded = null;
|
|
143
|
+
const out = await postBoardAnnouncement(payload(), {
|
|
144
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
145
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
146
|
+
post: async () => ({ ok: true, status: 200, message_id: '555000111' }),
|
|
147
|
+
recordMessage: async (row) => { recorded = row; },
|
|
148
|
+
log: () => {},
|
|
149
|
+
});
|
|
150
|
+
assert.equal(out.posted, true);
|
|
151
|
+
assert.deepEqual(recorded, { itemId: '11', channel: 'board_room', messageId: '555000111' });
|
|
152
|
+
assert.equal(out.recorded, '555000111');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('a poster that returns no id, or a failed remember, still counts as announced', async () => {
|
|
156
|
+
const quiet = {
|
|
157
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
158
|
+
getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
|
|
159
|
+
log: () => {},
|
|
160
|
+
};
|
|
161
|
+
// The pre-R22 shape (no return value at all) must not become a failure.
|
|
162
|
+
assert.equal((await postBoardAnnouncement(payload(), { ...quiet, post: async () => {} })).posted, true);
|
|
163
|
+
const out = await postBoardAnnouncement(payload(), {
|
|
164
|
+
...quiet,
|
|
165
|
+
post: async () => ({ message: { id: '7' } }),
|
|
166
|
+
recordMessage: async () => { throw new Error('db down'); },
|
|
167
|
+
});
|
|
168
|
+
assert.equal(out.posted, true, 'the nudge is already in the channel — a lost id is not a failed announcement');
|
|
169
|
+
assert.equal(out.recorded, undefined);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('the close is EDITED onto the same message, and the edit is stamped', async () => {
|
|
173
|
+
let edited = null; let stamped = null;
|
|
174
|
+
const out = await postBoardOutcome(closedPayload({ tally: { yes: 2, no: 0, total: 2 } }), {
|
|
175
|
+
getMessage: async (itemId) => { assert.equal(itemId, '11'); return { item_id: '11', channel: 'board_room', message_id: '555000111' }; },
|
|
176
|
+
edit: async (channel, messageId, text) => { edited = { channel, messageId, text }; },
|
|
177
|
+
markEdited: async (itemId) => { stamped = itemId; },
|
|
178
|
+
log: () => {},
|
|
179
|
+
});
|
|
180
|
+
assert.deepEqual({ edited: out.edited, posted: out.posted, skipped: out.skipped }, { edited: true, posted: false, skipped: null });
|
|
181
|
+
assert.equal(edited.channel, 'board_room');
|
|
182
|
+
assert.equal(edited.messageId, '555000111');
|
|
183
|
+
assert.ok(edited.text.includes('RATIFIED'), 'the summons now reads as the outcome');
|
|
184
|
+
assert.ok(!edited.text.includes('your vote is wanted'), 'nothing stale survives the edit');
|
|
185
|
+
assert.ok(edited.text.includes('/government?item=11#board-room'), 'the record is still one click away');
|
|
186
|
+
assert.equal(stamped, '11', 'edited_at answers "is this window still shown as open?" without asking Discord');
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a RETURN mirrors exactly like a pass — and names neither voter nor reason', async () => {
|
|
190
|
+
let text = null;
|
|
191
|
+
const out = await postBoardOutcome(closedPayload({ outcome: 'returned', cause: 'expiry', tally: { yes: 0, no: 1, total: 1 } }), {
|
|
192
|
+
getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
|
|
193
|
+
edit: async (_c, _m, t) => { text = t; },
|
|
194
|
+
markEdited: async () => {},
|
|
195
|
+
log: () => {},
|
|
196
|
+
});
|
|
197
|
+
assert.equal(out.edited, true);
|
|
198
|
+
assert.ok(text.includes('RETURNED to its author'));
|
|
199
|
+
assert.ok(text.includes('1 objection'), 'singular reads right at one');
|
|
200
|
+
assert.ok(text.includes('the window ran out'), 'an expiry close says so');
|
|
201
|
+
assert.ok(text.includes('in the hall'), 'the reason + flagged section live where the idea is');
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('a zero-vote pass states what happened instead of reading like a bug', () => {
|
|
205
|
+
const line = composeOutcomeLine(closedPayload({ cause: 'expiry', tally: { yes: 0, no: 0, total: 0 } }));
|
|
206
|
+
assert.ok(line.includes('nobody objected before the window closed'), 'an absence is nulled, never a block (ADR 0175 §3)');
|
|
207
|
+
assert.ok(line.includes('RATIFIED'));
|
|
208
|
+
const amendment = composeOutcomeLine(closedPayload({ subject_type: 'constitutional_amendment', tally: { yes: 3, no: 0 } }));
|
|
209
|
+
assert.ok(amendment.includes('constitutional amendment'), 'the second subject is named honestly here too');
|
|
210
|
+
assert.ok(amendment.includes('3 in favour, 0 objections'));
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('an unannounced item posts its outcome fresh rather than going silent', async () => {
|
|
214
|
+
let posted = null;
|
|
215
|
+
const out = await postBoardOutcome(closedPayload(), {
|
|
216
|
+
getMessage: async () => null,
|
|
217
|
+
post: async (channel, text) => { posted = { channel, text }; },
|
|
218
|
+
log: () => {},
|
|
219
|
+
});
|
|
220
|
+
assert.deepEqual({ edited: out.edited, posted: out.posted }, { edited: false, posted: true });
|
|
221
|
+
assert.equal(posted.channel, 'board_room');
|
|
222
|
+
assert.ok(posted.text.includes('RATIFIED'));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('every mirror failure is a NAMED skip — a close can never be failed by Discord', async () => {
|
|
226
|
+
assert.equal((await postBoardOutcome(null, { log: () => {} })).skipped, 'no_item');
|
|
227
|
+
assert.equal((await postBoardOutcome(closedPayload({ outcome: null }), { log: () => {} })).skipped, 'no_outcome');
|
|
228
|
+
const failed = await postBoardOutcome(closedPayload(), {
|
|
229
|
+
getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
|
|
230
|
+
edit: async () => { throw new Error('discord down'); },
|
|
231
|
+
log: () => {},
|
|
232
|
+
});
|
|
233
|
+
assert.deepEqual({ edited: failed.edited, posted: failed.posted, skipped: failed.skipped },
|
|
234
|
+
{ edited: false, posted: false, skipped: 'mirror_failed' },
|
|
235
|
+
'a failed EDIT never falls back to a second message — the room must not get two notices');
|
|
236
|
+
// An unreadable bookkeeping read degrades to "unannounced", not to a throw.
|
|
237
|
+
const unread = await postBoardOutcome(closedPayload(), {
|
|
238
|
+
getMessage: async () => { throw new Error('db down'); },
|
|
239
|
+
post: async () => {},
|
|
240
|
+
log: () => {},
|
|
241
|
+
});
|
|
242
|
+
assert.equal(unread.posted, true);
|
|
243
|
+
// A stamp that fails leaves the mirrored close reported as mirrored.
|
|
244
|
+
const unstamped = await postBoardOutcome(closedPayload(), {
|
|
245
|
+
getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
|
|
246
|
+
edit: async () => {},
|
|
247
|
+
markEdited: async () => { throw new Error('db down'); },
|
|
248
|
+
log: () => {},
|
|
249
|
+
});
|
|
250
|
+
assert.equal(unstamped.edited, true);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('government emits the close for BOTH outcomes, after commit, and keeps the pass contract pass-only (source pins)', () => {
|
|
254
|
+
const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
255
|
+
const close = board.slice(board.indexOf('async function closeItem'));
|
|
256
|
+
assert.ok(close.indexOf('closeBoardItem') < close.indexOf('emitItemClosed(closed'),
|
|
257
|
+
'the mirror event fires AFTER the close commits — the row is the durable fact');
|
|
258
|
+
const passEmit = close.indexOf("emitAsync('board.item.passed'");
|
|
259
|
+
assert.ok(passEmit < close.indexOf('emitItemClosed(closed'),
|
|
260
|
+
"the credit lane's contract event still goes first");
|
|
261
|
+
const closedEmit = board.slice(board.indexOf('async function emitItemClosed'));
|
|
262
|
+
assert.match(closedEmit, /emitAsync\('board\.item\.closed'/, 'the event exists');
|
|
263
|
+
assert.match(closedEmit, /outcome: closed\.outcome/, 'it carries the outcome — a return mirrors too');
|
|
264
|
+
assert.match(closedEmit, /tally: \{/, 'it carries the tally so no consumer re-reads votes');
|
|
265
|
+
const passBlock = board.slice(board.indexOf('function passPayload'), board.indexOf('// Close an item'));
|
|
266
|
+
assert.ok(!/tally/.test(passBlock), 'the pass CONTRACT payload stays the seven fields — no tally (ADR 0175 / the pass-event spec)');
|
|
267
|
+
const routes = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'routes', 'discord.js'), 'utf8');
|
|
268
|
+
assert.match(routes, /api\.on\('board\.item\.closed', \(payload\) => boardBroadcast\.postBoardOutcome\(payload\)\)/,
|
|
269
|
+
'the discord module subscribes to the close as well');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('the poster asks for the message back, the edit rings NOBODY, and the mirror table is discord-owned', () => {
|
|
273
|
+
const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
|
|
274
|
+
const core = src.slice(src.indexOf('async function postCore'), src.indexOf('// SECURITY (#871)'));
|
|
275
|
+
assert.match(core, /wait=true/, 'the POST asks Discord for the created message — that id is what R22 edits');
|
|
276
|
+
assert.match(core, /message_id: message && message\.id/, 'the id is surfaced to the caller');
|
|
277
|
+
const edit = src.slice(src.indexOf('async function editWebhook'));
|
|
278
|
+
assert.match(edit, /method: 'PATCH'/);
|
|
279
|
+
assert.match(edit, /\/messages\/\$\{encodeURIComponent\(String\(messageId\)\)\}/, 'a webhook may only edit its OWN message');
|
|
280
|
+
assert.match(edit, /allowed_mentions: \{ parse: \[\] \}/, 'an edit is an outcome notice, not a summons — it must never re-ring the board');
|
|
281
|
+
assert.ok(!/users/.test(edit), 'the mention exception stays scoped to the one post that means "your vote is wanted"');
|
|
282
|
+
const sql = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'migrations', 'discord_001_board_messages.sql'), 'utf8');
|
|
283
|
+
assert.match(sql, /CREATE TABLE IF NOT EXISTS discord_board_messages/);
|
|
284
|
+
assert.ok(!/REFERENCES government_board_items/.test(sql),
|
|
285
|
+
'no FK across the module boundary — the mirror never constrains government\'s schema');
|
|
286
|
+
assert.match(sql, /item_id\s+bigint\s+PRIMARY KEY/, 'one announcement per sitting, edited in place');
|
|
287
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'module.json'), 'utf8'));
|
|
288
|
+
assert.equal(manifest.contributes.migrations, true, 'the module now owns a migration and says so');
|
|
289
|
+
});
|