Skip to main content

Overview

ProposalBot is a timer-based bot that continuously monitors the NNS (Network Nervous System) governance canister for new proposals and automatically broadcasts them to subscribed OpenChat groups and channels. It uses topic-based filtering to ensure subscribers only receive proposals relevant to their interests.

Key Features

  • Automated monitoring: Timer-based polling of NNS governance proposals
  • Topic filtering: Subscribe to specific proposal topics (RVM, SCM, etc.)
  • Batch processing: Groups related proposals (e.g., SCM with same git hash)
  • Smart threading: Creates proposal threads with links to NNS Proposal Group
  • Message matching: Links proposals to existing messages in NNS Group

How It Works

ProposalBot runs on a configurable timer (default 5 minutes) that:
  1. Fetches new proposals from the NNS governance canister since the last check
  2. Matches proposals with existing messages in the NNS Proposal Group
  3. Groups proposals by type and git hash for batch updates
  4. Sends notifications to subscribed channels/groups based on topic filters
  5. Creates threads with links back to the original proposal messages
ProposalBot requires the timer to be initialized with initTimer before it starts monitoring proposals.

Architecture

Subscriber Type

Subscribers define where and what proposal updates to receive:
type Subscriber = {
  #Group : {
    topics : [Int32];      // Topic IDs to monitor
    groupCanister : Text;  // Group canister ID
  };
  #Channel : {
    topics : [Int32];          // Topic IDs to monitor
    communityCanister : Text;  // Community canister ID
    channelId : Nat;           // Channel ID within community
  };
};

Proposal Topics

Common NNS proposal topics:
topics
[Int32]
  • 13 - RVM (Replica Version Management)
  • 8 - SCM (Subnet Canister Management)
  • Other topics as defined by NNS governance

Internal State

type ProposalBotModel = {
  var lastProposalId : ?Nat64;         // Last processed proposal
  var timerId : ?Nat;                  // Active timer ID
  var latestNNSMessageIndex : ?Nat32;  // Latest message in NNS Group
  proposalsLookup : Map<Nat64, Proposal>; // Pending proposals
  var numberOfTicksSinceUpdate : Nat;  // Ticks since last SCM batch
  subscribers : Map<Text, Subscriber>; // Active subscriptions
};

Getting Started

1

Initialize the bot

Register your canister as an OpenChat bot:
dfx canister call OCBot initBot '("proposal_bot", opt "Proposal Bot")' --network ic
2

Join target communities

Add the bot to communities where it will post updates:
dfx canister call OCBot tryJoinCommunity '(
  "community_canister_id", 
  null
)' --network ic
3

Add subscribers

Subscribe channels or groups with topic filters:
dfx canister call OCBot addSubscriber '(
  variant { 
    Channel = record {
      topics = vec { 13; 8 };  # RVM and SCM topics
      communityCanister = "community_canister_id";
      channelId = 42
    }
  },
  null  # No invite code
)' --network ic
4

Start the timer

Initialize the timer to begin automatic monitoring:
# Use default interval (5 minutes)
dfx canister call OCBot initTimer '(null)' --network ic

# Use custom interval (10 minutes)
dfx canister call OCBot initTimer '(opt 600)' --network ic

API Reference

initTimer

Starts the recurring timer that monitors for new proposals.
tickrateInSeconds
?Nat
Timer interval in seconds. Default: 300 (5 minutes)
Authorization: Custodians only Returns: Result<(), Text>
# Default 5 minute interval
dfx canister call OCBot initTimer '(null)' --network ic

# Custom 10 minute interval
dfx canister call OCBot initTimer '(opt 600)' --network ic

cancelTimer

Stops the automatic proposal monitoring. Authorization: Custodians only Returns: Result<(), Text>
dfx canister call OCBot cancelTimer '()' --network ic

update

Manually triggers a proposal update cycle (useful for testing).
start
?Nat64
Optional proposal ID to start from. If null, continues from last processed proposal.
Authorization: Custodians only Returns: async ()
# Update from last position
dfx canister call OCBot update '(null)' --network ic

# Update from specific proposal ID
dfx canister call OCBot update '(opt 123456)' --network ic

addSubscriber

Adds a new subscriber for proposal updates.
sub
Subscriber
required
Subscriber configuration with topics and destination
inviteCode
?Nat64
Optional invite code if joining private group/channel
Authorization: Custodians only Returns: Result<(), Text>
dfx canister call OCBot addSubscriber '(
  variant { 
    Channel = record {
      topics = vec { 13; 8 };  # RVM and SCM
      communityCanister = "xyz...";
      channelId = 42
    }
  },
  null
)' --network ic

updateSubscriber

Updates the topic filter for an existing subscriber.
id
Text
required
Subscriber ID (group canister ID or channel ID as text)
newTopics
[Int32]
required
New array of topic IDs to subscribe to
Authorization: Custodians only Returns: Result<(), Text>
# Update to monitor both RVM and SCM
dfx canister call OCBot updateSubscriber '(
  "group_canister_id",
  vec { 13; 8 }
)' --network ic

# Update to monitor RVM only
dfx canister call OCBot updateSubscriber '(
  "42",  # Channel ID as text
  vec { 13 }
)' --network ic

deleteSubscriber

Removes a subscriber from receiving updates.
id
Text
required
Subscriber ID to remove
Authorization: Custodians only Returns: Result<(), Text>
dfx canister call OCBot deleteSubscriber '("group_canister_id")' --network ic

getSubscribers

Retrieves all active subscribers. Returns: [Subscriber]
dfx canister call OCBot getSubscribers '()' --network ic

Update Mechanism

Proposal Lifecycle

  1. Discovery: Timer triggers update() which fetches new proposals from NNS
  2. Mapping: Proposals are added to proposalsLookup map
  3. Matching: Bot matches proposals with messages in NNS Proposal Group
  4. Batching: Related proposals are grouped (SCM proposals with same git hash)
  5. Broadcasting: Messages sent to subscribers based on topic filters
  6. Cleanup: Processed proposals removed from lookup map

Batch Logic for SCM Proposals

SCM (Subnet Canister Management) proposals are intelligently batched:
// Proposals with the same git hash are grouped together
let proposalHash = extractGitHash(title, description);

// Send batch when:
// - 10+ proposals with same hash accumulated (PENDING_SCM_LIMIT)
// - OR 3+ update cycles without new proposals (MAX_TICKS_WITHOUT_UPDATE)

Batch Strategy

Separate proposals: Non-batched, separate build process detectedBatch proposals: Same git hash, sent in groups of up to 10Wait for quiet: Waits 3 timer cycles (15 min default) for more proposals

Message Matching

ProposalBot efficiently matches proposals with existing messages:
func matchProposalsWithMessages(
  groupId : Text, 
  pending : ProposalsLookup
) : async* Result<(), Text>
  • Scans NNS Proposal Group in batches of 100 messages
  • Extracts proposal IDs from message content
  • Updates proposalsLookup with message indexes
  • Stops when all proposals matched or reaches last processed index

Message Formatting

Single Proposal Thread

[Proposal Header with details]

→ Thread Reply:
"View in NNS Proposals Group: [link to message]"

Batch Proposal Thread

[Batch header with multiple proposals]
Proposal 123: Title
Proposal 124: Title
Proposal 125: Title
...

→ Thread Reply:
"Proposal 123: [link]
Proposal 124: [link]
Proposal 125: [link]"

Testing and Debugging

Manual Update

Trigger an update cycle manually:
# Start from last position
dfx canister call OCBot update '(null)' --network ic

# Start from specific proposal
dfx canister call OCBot update '(opt 123000)' --network ic

Check State

dfx canister call OCBot testGetLastProposalId '()' --network ic

Reset State

Clear internal state for testing:
# Clear proposals lookup and message index
dfx canister call OCBot testResetState '()' --network ic

Configuration

Constants

let FIND_PROPOSALS_BATCH_SIZE : Nat32 = 100;    // Messages per batch
let PENDING_SCM_LIMIT = 10;                      // Proposals per SCM batch
let MAX_TICKS_WITHOUT_UPDATE = 3;                // Wait for quiet period
let NNS_PROPOSAL_GROUP_ID = "labxu-baaaa-aaaaf-anb4q-cai";
let GOVERNANCE_ID = "rrkah-fqaaa-aaaaa-aaaaq-cai"; // NNS Governance

Topic Filtering

Proposals are filtered using topic IDs:
// Exclude topics 8 (SCM) and 13 (RVM) from generic processing
let topics = processIncludeTopics(NNSFunctions, [8, 13]);

let res = await* proposalService.listProposalsAfterId(
  GOVERNANCE_ID, 
  after, 
  {
    excludeTopic = topics;
    omitLargeFields = ?false;
  }
);

Best Practices

Timer Configuration

  • Default (5 min): Good balance for most use cases
  • Shorter intervals: Use for time-sensitive communities
  • Longer intervals: Reduce costs for less active communities
  • Remember: Shorter intervals = higher cycle costs

Topic Selection

  • Only subscribe to relevant topics to reduce noise
  • RVM (13) and SCM (8) are the most common
  • Test with a single topic before adding more
  • Consider creating separate channels for different topics

Performance

  • Bot uses efficient batch lookups (100 messages at a time)
  • SCM proposals batched to reduce message spam
  • Maintains state to avoid reprocessing proposals
  • Uses “wait for quiet” logic for optimal batching

Troubleshooting

Common issues:
# Check if timer already exists
dfx canister call OCBot initTimer '(null)' --network ic
# Error: "Timer already created"

# Solution: Cancel existing timer first
dfx canister call OCBot cancelTimer '()' --network ic
dfx canister call OCBot initTimer '(null)' --network ic
Debugging steps:
  1. Check last processed proposal:
dfx canister call OCBot testGetLastProposalId '()' --network ic
  1. Manually trigger update:
dfx canister call OCBot update '(null)' --network ic
  1. Check subscriber list:
dfx canister call OCBot getSubscribers '()' --network ic
  1. Verify topics in subscriber match proposal topics
“Subscriber already exists”: Delete first, then re-add
dfx canister call OCBot deleteSubscriber '("subscriber_id")' --network ic
dfx canister call OCBot addSubscriber '(...)' --network ic
Join errors: Ensure bot has access to the group/channel and invite code is correct
SCM batching requires:
  1. Proposals must have the same git hash in description
  2. Either 10+ proposals accumulated OR 3 timer ticks elapsed
  3. Check logs for batching activity
dfx canister call OCBot getLogs '(null)' --network ic
Optimize cycle usage:
  • Increase timer interval: initTimer(opt 600) for 10 minutes
  • Reduce number of subscribers
  • Limit topics per subscriber
  • Check for stuck proposals in lookup map
# Clear stuck state if needed
dfx canister call OCBot testResetState '()' --network ic

Advanced Features

Post-Upgrade Timer Restoration

The timer automatically restarts after canister upgrades:
system func postupgrade() {
  if (Option.isSome(proposalBotData.timerId)) {
    proposalBotData.timerId := ?Timer.recurringTimer<system>(
      #seconds(5 * 60), 
      func() : async () {
        await proposalBot.update(proposalBotData.lastProposalId);
      }
    );
  }
};

Reentrancy Protection

Update cycles are protected from concurrent execution:
var updateState : UpdateState = #Stopped;

public func update(after : ?Nat64) : async () {
  if (updateState == #Running) {
    logService.logWarn("Update already running");
    return;
  };
  updateState := #Running;
  // ... perform update ...
  updateState := #Stopped;
};

TallyBot

Track neuron votes on proposals

Bot Service

Core bot functionality and OpenChat integration

Governance Service

NNS governance canister integration

Getting Started

Set up your first OpenChat bot

Build docs developers (and LLMs) love